Compare commits

..

1 Commits

Author SHA1 Message Date
Jai A
072fa47129 NTEX migration start 2024-12-24 18:51:55 -07:00
908 changed files with 27505 additions and 46237 deletions

View File

@@ -1,6 +1,3 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
# Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
[build]
rustflags = ["--cfg", "tokio_unstable"]

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
* text=auto eol=lf

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true
@@ -16,7 +16,7 @@ body:
id: version
attributes:
label: What version of the Modrinth App are you using?
description: Find this in ⚙️ Settings (bottom right) -> After Modrinth App (bottom left)
description: Find this in ⚙️ Settings (bottom right) -> About -> App version.
validations:
required: true
- type: dropdown

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true

View File

@@ -7,7 +7,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -7,13 +7,11 @@ on:
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
merge_group:
types: [checks_requested]

View File

@@ -38,10 +38,8 @@ jobs:
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with:
file: ./apps/labrinth/Dockerfile
context: ./apps/labrinth
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}

View File

@@ -6,11 +6,9 @@ on:
tags:
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- .github/workflows/app-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
@@ -32,16 +30,16 @@ jobs:
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
rustflags: ''
target: x86_64-apple-darwin
components: rustfmt, clippy
targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: Rust setup
if: "!startsWith(matrix.platform, 'macos')"
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
rustflags: ''
components: rustfmt, clippy
- name: Setup rust cache
uses: actions/cache@v4
@@ -55,11 +53,11 @@ jobs:
!target/release/bundle/*/*.app.tar.gz
!target/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/appimage/*.AppImage
!target/release/bundle/appimage/*.AppImage.tar.gz
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
!target/release/bundle/deb/*.deb
!target/release/bundle/rpm/*.rpm
!target/release/bundle/*/*.AppImage
!target/release/bundle/*/*.AppImage.tar.gz
!target/release/bundle/*/*.AppImage.tar.gz.sig
!target/release/bundle/*/*.deb
!target/release/bundle/*/*.rpm
!target/release/bundle/msi/*.msi
!target/release/bundle/msi/*.msi.zip
@@ -72,10 +70,10 @@ jobs:
restore-keys: |
${{ runner.os }}-rust-target-
- name: Setup Node.js
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
node-version: 20
- name: Install pnpm via corepack
shell: bash

View File

@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: ['main']
pull_request:
types: [opened, synchronize]
merge_group:
@@ -10,69 +10,71 @@ on:
jobs:
build:
name: Lint and Test
name: Build, Test, and Lint
runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
steps:
- name: 📥 Check out code
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🧰 Install build dependencies
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
- name: Setup Node.JS environment
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
node-version: 20
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
rustflags: ''
components: clippy, rustfmt
cache: false
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: 🧰 Setup nextest
uses: taiki-e/install-action@nextest
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false
no-default-features: true
features: rustls,postgres
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
- name: Install dependencies
run: pnpm install
- name: ⚙️ Start services
run: docker compose up --wait
- name: Build
run: pnpm build
env:
SQLX_OFFLINE: true
- name: ⚙️ Setup Labrinth environment and database
working-directory: apps/labrinth
run: |
cp .env.local .env
sqlx database setup
- name: Lint
run: pnpm lint
env:
SQLX_OFFLINE: true
- name: 🔍 Lint and test
run: pnpm run ci
- name: Start docker compose
run: docker compose up -d
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

4
.idea/code.iml generated
View File

@@ -10,11 +10,9 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>

1
.nvmrc
View File

@@ -1 +0,0 @@
20.19.2

View File

@@ -2,7 +2,6 @@
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}

7466
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +1,24 @@
[workspace]
resolver = "2"
resolver = '2'
members = [
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
'./packages/app-lib',
'./apps/app-playground',
'./apps/app',
'./apps/labrinth',
'./apps/daedalus_client',
'./packages/daedalus',
]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.24", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.19", default-features = false }
rust_decimal = { version = "1.37.1", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.38.1"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.12.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "2.2.7"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.4"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.2.2"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
# Optimize for speed and reduce size on release builds
[profile.release]
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
[profile.dev.package.sqlx-macros]
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev ="e88d4a1" }

View File

@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "0.9.5",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,21 +9,19 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit"
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
},
"dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"@vintl/vintl": "^4.4.1",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
@@ -40,12 +38,11 @@
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.5.4",
"eslint-plugin-turbo": "^2.1.1",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",

View File

@@ -1,37 +1,28 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import {
ArrowBigUpDashIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
MaximizeIcon,
MinimizeIcon,
HomeIcon,
LibraryIcon,
PlusIcon,
RestoreIcon,
RightArrowIcon,
SettingsIcon,
WorldIcon,
XIcon,
DownloadIcon,
CompassIcon,
MinimizeIcon,
MaximizeIcon,
RestoreIcon,
LogOutIcon,
RightArrowIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts'
import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -40,12 +31,12 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { isDev, getOS, restartApp } from '@/helpers/utils.js'
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { create_profile_and_install_from_file } from './helpers/pack'
import { install_from_file } from './helpers/pack'
import { useError } from '@/store/error.js'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
@@ -59,7 +50,7 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get as getCreds, logout, login } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs'
@@ -69,8 +60,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const news = ref([])
@@ -176,17 +165,11 @@ async function setupApp() {
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
'criticalAnnouncements',
true,
)
.then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
.catch(() => {
console.log(
`No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
)
})
).then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
if (res && res.articles) {
@@ -273,19 +256,25 @@ themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const forceSidebar = ref(false)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const showAd = computed(() => !(!sidebarVisible.value || hasPlus.value))
router.afterEach((to) => {
forceSidebar.value = to.path.startsWith('/browse') || to.path.startsWith('/project')
})
const currentTimeout = ref(null)
watch(
showAd,
() => {
if (!showAd.value) {
if (currentTimeout.value) clearTimeout(currentTimeout.value)
hide_ads_window(true)
} else {
init_ads_window(true)
currentTimeout.value = setTimeout(() => {
init_ads_window(true)
}, 400)
}
},
{ immediate: true },
@@ -312,7 +301,7 @@ async function handleCommand(e) {
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local mrpack given a path
if (e.path.endsWith('.mrpack')) {
await create_profile_and_install_from_file(e.path).catch(handleError)
await install_from_file(e.path).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@@ -375,7 +364,7 @@ function handleAuxClick(e) {
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<div v-if="stateInitialized" class="app-grid-layout relative">
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
@@ -388,9 +377,6 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Home'" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@@ -457,20 +443,6 @@ function handleAuxClick(e) {
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex p-3">
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />
</button>
</div>
<Breadcrumbs class="pt-[2px]" />
</div>
<section class="flex ml-auto items-center">
@@ -492,7 +464,7 @@ function handleAuxClick(e) {
<RunningAppBar />
</Suspense>
</div>
<section v-if="!nativeDecorations" class="window-controls" data-tauri-drag-region-exclude>
<section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</Button>
@@ -540,16 +512,6 @@ function handleAuxClick(e) {
width: 'calc(100% - var(--right-bar-width))',
}"
></div>
<div
v-if="criticalErrorMessage"
class="m-6 mb-0 flex flex-col border-red bg-bg-red rounded-2xl border-2 border-solid p-4 gap-1 font-semibold text-contrast"
>
<h1 class="m-0 text-lg font-extrabold">{{ criticalErrorMessage.header }}</h1>
<div
class="markdown-body text-primary"
v-html="renderString(criticalErrorMessage.body ?? '')"
></div>
</div>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
@@ -599,7 +561,7 @@ function handleAuxClick(e) {
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
{{ dayjs(item.date).fromNow() }}
</p>
</a>
<hr
@@ -621,6 +583,12 @@ function handleAuxClick(e) {
<PromotionWrapper />
</template>
</div>
<div class="view">
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region>
<h1>{{ criticalErrorMessage.header }}</h1>
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div>
</div>
</div>
</div>
<URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" sidebar />
@@ -723,14 +691,6 @@ function handleAuxClick(e) {
grid-area: status;
}
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
[data-tauri-drag-region-exclude] {
-webkit-app-region: no-drag;
}
.app-contents {
position: absolute;
z-index: 1;
@@ -744,7 +704,7 @@ function handleAuxClick(e) {
display: grid;
grid-template-columns: 1fr 0px;
// transition: grid-template-columns 0.4s ease-in-out;
transition: grid-template-columns 0.4s ease-in-out;
&.sidebar-enabled {
grid-template-columns: 1fr 300px;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 937 KiB

View File

@@ -2,44 +2,8 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
}
.font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace;
}
:root {
font-family: var(--font-standard, sans-serif), sans-serif;
font-family: var(--font-standard);
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);

View File

@@ -10,6 +10,7 @@ import {
StopCircleIcon,
ExternalIcon,
EyeIcon,
ChevronRightIcon,
} from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
@@ -25,7 +26,6 @@ import { trackEvent } from '@/helpers/analytics'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui'
const router = useRouter()
@@ -44,9 +44,7 @@ const props = defineProps({
})
const actualInstances = computed(() =>
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
props.instances.filter((x) => x && x.instances && x.instances[0]),
)
const modsRow = ref(null)
@@ -183,10 +181,6 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
@@ -210,21 +204,16 @@ const calculateCardsPerRow = () => {
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
resizeObserver.value.observe(rowContainer.value)
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
resizeObserver.value.unobserve(rowContainer.value)
})
</script>
@@ -238,10 +227,17 @@ onUnmounted(() => {
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<router-link
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
:class="{ 'mt-1': rowIndex > 0 }"
:to="row.route"
>
{{ row.label }}
</HeadingLink>
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</router-link>
<section
v-if="row.instance"
ref="modsRow"

View File

@@ -10,12 +10,12 @@
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
@@ -28,17 +28,12 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`" />
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<div>
<h4>{{ selectedAccount.profile.name }}</h4>
<h4>{{ selectedAccount.username }}</h4>
<p>Selected</p>
</div>
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<TrashIcon />
</Button>
</div>
@@ -49,12 +44,12 @@
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.profile.id}/128`" class="icon" />
<p>{{ account.profile.name }}</p>
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<TrashIcon />
</Button>
</div>
@@ -106,16 +101,16 @@ defineExpose({
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
accounts.value.filter((account) => defaultUser.value !== account.id),
)
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
accounts.value.find((account) => account.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
emit('change')
}

View File

@@ -19,7 +19,6 @@
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))

View File

@@ -1,20 +1,11 @@
<script setup>
import {
CheckIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
CopyIcon,
} from '@modrinth/assets'
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { cancel_directory_change } from '@/helpers/settings.js'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -22,7 +13,6 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred')
const errorType = ref('unknown')
@@ -92,7 +82,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError)
await set_default_user(loggedIn.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@@ -128,26 +118,6 @@ async function repairInstance() {
}
loadingRepair.value = false
}
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
@@ -274,9 +244,16 @@ async function copyToClipboard(text) {
</div>
</template>
<template v-else>
{{ debugInfo }}
{{ error.message ?? error }}
</template>
<template v-if="hasDebugInfo">
<template
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<hr />
<p>
If nothing is working and you need help, visit
@@ -284,39 +261,16 @@ async function copyToClipboard(text) {
and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent:
</p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
<div class="input-group push-right">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
</div>
<template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible>
</div>
</template>
</div>
</ModalWrapper>
</template>

View File

@@ -151,7 +151,7 @@ const exportPack = async () => {
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox

View File

@@ -1,17 +1,10 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onUnmounted, ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
@@ -19,9 +12,10 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime()
dayjs.extend(relativeTime)
const props = defineProps({
instance: {
@@ -41,15 +35,12 @@ const props = defineProps({
})
const playing = ref(false)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
currentEvent.value === 'installing' || (currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const installing = computed(() => props.instance.install_stage !== 'installed')
const router = useRouter()
@@ -65,7 +56,6 @@ const checkProcess = async () => {
const play = async (e, context) => {
e?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
@@ -75,7 +65,6 @@ const play = async (e, context) => {
source: context,
})
})
loading.value = false
}
const stop = async (e, context) => {
@@ -91,12 +80,6 @@ const stop = async (e, context) => {
})
}
const repair = async (e) => {
e?.stopPropagation()
await finish_install(props.instance)
}
const openFolder = async () => {
await showProfileInFolder(props.instance.path)
}
@@ -135,7 +118,7 @@ onUnmounted(() => unlisten())
<template>
<template v-if="compact">
<div
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
class="button-base card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer active:scale-[0.98] transition-transform"
@click="seeInstance"
@mouseenter="checkProcess"
>
@@ -172,9 +155,7 @@ onUnmounted(() => unlisten())
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</span>
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
</div>
</div>
</template>
@@ -210,15 +191,6 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"

View File

@@ -1,5 +1,5 @@
<template>
<ModalWrapper ref="modal" header="Creating an instance">
<ModalWrapper ref="modal" header="Create instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>
@@ -199,16 +199,16 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
CodeIcon,
FolderOpenIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
CodeIcon,
FolderOpenIcon,
InfoIcon,
FolderSearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
@@ -263,7 +263,7 @@ defineExpose({
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await create_profile_and_install_from_file(paths[0]).catch(handleError)
await install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@@ -419,7 +419,7 @@ const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
await install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',

View File

@@ -70,7 +70,7 @@ const onHide = () => {
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button

View File

@@ -7,7 +7,7 @@
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>

View File

@@ -60,7 +60,7 @@ const toTransparent = computed(() => {
<template>
<div
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
class="card-shadow button-base bg-bg-raised rounded-xl overflow-clip cursor-pointer active:scale-[0.98] transition-transform"
@click="router.push(`/project/${project.slug}`)"
>
<div

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { init_ads_window } from '@/helpers/ads.js'
import { ChevronRightIcon } from '@modrinth/assets'
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js'
const adsWrapper = ref(null)
@@ -28,12 +29,27 @@ function updateAdPosition() {
initDevicePixelRatioWatcher()
}
}
async function openPlusLink() {
await record_ads_click()
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com')
}
</script>
<template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<button
class="mt-auto items-center gap-1 text-purple hover:underline bg-transparent border-none text-left cursor-pointer outline-none"
@click="openPlusLink"
>
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</button>
</div>
</div>
</template>

View File

@@ -14,10 +14,7 @@
<div v-if="selectedProcess" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link
class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
>
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
{{ selectedProcess.profile.name }}
</router-link>
<div

View File

@@ -1,6 +1,6 @@
<template>
<div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
class="card-shadow button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
@click="
() => {
emit('open')
@@ -12,7 +12,21 @@
"
>
<div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
<Avatar
:src="project.icon_url"
size="96px"
class="search-icon origin-top transition-all"
:class="{ 'scale-[0.85]': installed, 'brightness-50': installing }"
/>
<div v-if="installing" class="rounded-2xl absolute inset-0 flex items-center justify-center">
<SpinnerIcon class="h-8 w-8 animate-spin" />
</div>
<div
v-if="installed"
class="absolute shadow-sm font-semibold bottom-0 w-full p-1 bg-button-bg rounded-full text-xs justify-center items-center flex gap-1 text-brand border-[1px] border-solid border-[--color-button-border]"
>
<CheckIcon class="shrink-0 stroke-[3px]" /> Installed
</div>
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
@@ -26,42 +40,6 @@
</div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server
</template>
<template
v-else-if="
(project.client_side === 'optional' || project.client_side === 'required') &&
(project.server_side === 'optional' || project.server_side === 'unsupported')
"
>
Client
</template>
<template
v-else-if="
(project.server_side === 'optional' || project.server_side === 'required') &&
(project.client_side === 'optional' || project.client_side === 'unsupported')
"
>
Server
</template>
<template
v-else-if="
project.client_side === 'unsupported' && project.server_side === 'unsupported'
"
>
Unsupported
</template>
<template
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
>
Client and server
</template>
</div>
<div
v-for="tag in categories"
:key="tag"
@@ -87,8 +65,19 @@
</span>
</div>
<div class="mt-auto relative">
<div class="absolute bottom-0 right-0 w-fit">
<ButtonStyled color="brand" type="outlined">
<div
class="flex items-center gap-2 group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all"
>
<HistoryIcon class="shrink-0" />
<span>
<span class="text-secondary">Updated</span>
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
</span>
</div>
<div
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
>
<ButtonStyled color="brand">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@@ -117,18 +106,23 @@
</template>
<script setup>
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets'
import {
SpinnerIcon,
TagsIcon,
DownloadIcon,
HeartIcon,
PlusIcon,
CheckIcon,
HistoryIcon,
} from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
@@ -171,9 +165,6 @@ async function install() {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import {
UserPlusIcon,
MoreVerticalIcon,
@@ -18,11 +18,9 @@ import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: unknown | null
signIn: () => void
signIn: () => void2
}>()
const userCredentials = computed(() => props.credentials)
@@ -207,9 +205,7 @@ onUnmounted(() => {
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template>
</p>
<p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">

View File

@@ -1,7 +1,7 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'
@@ -13,17 +13,15 @@ const confirmModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
show: (projectVal, versionIdVal, callback) => {
project.value = projectVal
versionId.value = versionIdVal
installing.value = false
confirmModal.value.show()
onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart')
},
@@ -38,7 +36,6 @@ async function install() {
versionId.value,
project.value.title,
project.value.icon_url,
onCreateInstance.value,
).catch(handleError)
trackEvent('PackInstall', {
id: project.value.id,

View File

@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
@@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
return editProfile
})

View File

@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts'
import { get } from '@/helpers/settings'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl()

View File

@@ -3,9 +3,9 @@ import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
const { formatMessage } = useVIntl()
@@ -114,6 +114,7 @@ const messages = defineMessages({
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {

View File

@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()
@@ -41,10 +41,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
@@ -84,7 +80,6 @@ function proceed() {
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
}>()
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
</template>

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()
@@ -43,7 +43,7 @@ function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
props.onHide?.()
props.onHide()
}
</script>

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { watch, ref } from 'vue'
import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
@@ -25,13 +24,13 @@ watch(
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
(theme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
:theme-options="themeStore.themeOptions"
system-theme-color="system"
/>
@@ -47,6 +46,7 @@ watch(
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
@@ -61,7 +61,16 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
<Toggle
id="native-decorations"
:model-value="settings.native_decorations"
:checked="settings.native_decorations"
@update:model-value="
(e) => {
settings.native_decorations = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
@@ -69,7 +78,16 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
<Toggle
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:checked="settings.hide_on_process_start"
@update:model-value="
(e) => {
settings.hide_on_process_start = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
@@ -81,28 +99,10 @@ watch(
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
@@ -111,6 +111,7 @@ watch(
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
:checked="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
@@ -57,7 +57,16 @@ watch(
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
<Toggle
id="fullscreen"
:model-value="settings.force_fullscreen"
:checked="settings.force_fullscreen"
@update:model-value="
(e) => {
settings.force_fullscreen = e
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">

View File

@@ -2,15 +2,18 @@
import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
import { get, set } from '@/helpers/settings'
const themeStore = useTheming()
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
const settings = ref(await get())
const options = ref(['project_background', 'page_path'])
function setFeatureFlag(key: string, value: boolean) {
function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -18,7 +21,7 @@ function setFeatureFlag(key: string, value: boolean) {
watch(
settings,
async () => {
await setSettings(settings.value)
await set(settings.value)
},
{ deep: true },
)
@@ -27,14 +30,15 @@ watch(
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
{{ option }}
</h2>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
:model-value="getStoreValue(option)"
:checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
@@ -30,7 +30,16 @@ watch(
option, you opt out and ads will no longer be shown based on your interests.
</p>
</div>
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
<Toggle
id="personalized-ads"
:model-value="settings.personalized_ads"
:checked="settings.personalized_ads"
@update:model-value="
(e) => {
settings.personalized_ads = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between gap-4">
@@ -42,7 +51,16 @@ watch(
longer be collected.
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
<Toggle
id="opt-out-analytics"
:model-value="settings.telemetry"
:checked="settings.telemetry"
@update:model-value="
(e) => {
settings.telemetry = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between gap-4">
@@ -57,6 +75,10 @@ watch(
as those added by mods. (app restart required to take effect)
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
<Toggle
id="disable-discord-rpc"
v-model="settings.discord_rpc"
:checked="settings.discord_rpc"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings.js'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'

View File

@@ -1,228 +0,0 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import {
EyeIcon,
FolderOpenIcon,
MoreVerticalIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process'
import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'stop'): void
}>()
const props = defineProps<{
instance: GameInstance
}>()
const loadingModpack = ref(!!props.instance.linked_data)
const modpack = ref()
if (props.instance.linked_data) {
nextTick().then(async () => {
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
loadingModpack.value = false
})
}
const instanceIcon = computed(() => props.instance.icon_path)
const loader = computed(() => {
if (props.instance.loader === 'vanilla') {
return 'Minecraft'
} else if (props.instance.loader === 'neoforge') {
return 'NeoForge'
} else {
return capitalizeString(props.instance.loader)
}
})
const loading = ref(false)
const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
})
emit('play')
loading.value = false
}
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
emit('stop')
loading.value = false
}
const unlistenProcesses = await process_listener(async () => {
await checkProcess()
})
const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0
}
onMounted(() => {
checkProcess()
})
onUnmounted(() => {
unlistenProcesses()
})
</script>
<template>
<SmartClickable>
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:tint-by="instance.path"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ instance.name }}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
instance.last_played
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
: null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
>
<template v-if="instance.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(instance.last_played.toISOString()),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/project/${modpack.id}`"
>
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
<span class="truncate">{{ modpack.title }}</span>
</router-link>
({{ loader }} {{ instance.game_version }})
</span>
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
<SpinnerIcon class="animate-spin shrink-0" />
<span class="truncate">Loading modpack...</span>
</span>
<span v-else class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'open-instance',
shown: !!instance.path,
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
},
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>

View File

@@ -1,304 +0,0 @@
<script setup lang="ts">
import {
type ServerWorld,
type ServerData,
type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier,
get_profile_protocol_version,
refreshServerData,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts'
import { kill, run } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
recentInstances: GameInstance[]
}>()
const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
type BaseJumpBackInItem = {
last_played: Dayjs
instance: GameInstance
}
type InstanceJumpBackInItem = BaseJumpBackInItem & {
type: 'instance'
}
type WorldJumpBackInItem = BaseJumpBackInItem & {
type: 'world'
world: WorldWithProfile
}
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
async function populateJumpBackIn() {
console.info('Repopulating jump back in...')
const worldItems: WorldJumpBackInItem[] = []
if (showWorlds.value) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) {
return
}
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
world: world,
instance: instance,
})
})
const servers: {
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
instancePath: item.instance.path,
address: (item.world as ServerWorld).address,
}))
// fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all(
[...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get profile protocol for: ${path} `)
}),
),
)
// initialize server data
servers.forEach(({ address }) => {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
})
// fetch each server's data
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
)
}
const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue
}
instanceItems.push({
type: 'instance',
last_played: dayjs(instance.last_played),
instance: instance,
})
}
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN)
}
async function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
}
}
async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
})
}
async function stopInstance(path: string) {
await kill(path).catch(handleError)
trackEvent('InstanceStop', {
source: 'RecentWorldsList',
})
}
const currentProfile = ref<string>()
const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => {
await checkProcesses()
})
const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
const runningInstances = ref<string[]>([])
type ProcessMetadata = {
uuid: string
profile_path: string
start_time: string
}
const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined
currentWorld.value = undefined
}
runningInstances.value = runningPaths
}
onMounted(() => {
checkProcesses()
})
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
})
</script>
<template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.path)"
:playing-world="
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined
"
supports-quick-play
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.path]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-path="item.instance.path"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
@refresh="
() =>
item.world.type === 'server'
? refreshServer(item.world.address, item.instance.path)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world)
}
"
@play-instance="
() => {
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" />
</template>
</div>
</div>
</template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
</style>

View File

@@ -1,506 +0,0 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
} from '@modrinth/ui'
import {
IssuesIcon,
EyeIcon,
ClipboardCopyIcon,
EditIcon,
FolderOpenIcon,
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
TrashIcon,
UpdatedIcon,
UserIcon,
XIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
import { copyToClipboard } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>()
const props = withDefaults(
defineProps<{
world: World
playingInstance?: boolean
playingWorld?: boolean
startingInstance?: boolean
supportsQuickPlay?: boolean
currentProtocol?: number | null
highlighted?: boolean
// Server only
refreshing?: boolean
serverStatus?: ServerStatus
renderedMotd?: string
// Singleplayer only
gameMode?: {
icon: Component
message: MessageDescriptor
}
// Instance
instancePath?: string
instanceName?: string
instanceIcon?: string
}>(),
{
playingInstance: false,
playingWorld: false,
startingInstance: false,
supportsQuickPlay: false,
currentProtocol: null,
refreshing: false,
serverStatus: undefined,
renderedMotd: undefined,
gameMode: undefined,
instancePath: undefined,
instanceName: undefined,
instanceIcon: undefined,
},
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol,
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({
hardcore: {
id: 'instance.worlds.hardcore',
defaultMessage: 'Hardcore mode',
},
cantConnect: {
id: 'instance.worlds.cant_connect',
defaultMessage: "Can't connect to server",
},
aMinecraftServer: {
id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server',
},
noQuickPlay: {
id: 'instance.worlds.no_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
},
gameAlreadyOpen: {
id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open',
},
copyAddress: {
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
},
viewInstance: {
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
},
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
},
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
})
</script>
<template>
<SmartClickable>
<template v-if="instancePath" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
:class="{
'world-item-highlighted': highlighted,
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
"
size="48px"
/>
<div class="flex flex-col justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
>
<UserIcon
aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px"
/>
{{ formatMessage(commonMessages.singleplayerLabel) }}
</div>
<div
v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
Loading...
</template>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
Incompatible version {{ serverStatus.version?.name }}
</span>
</template>
<template v-else>
<SignalIcon
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0"
:class="{
'smart-clickable:allow-pointer-events': serverStatus,
}"
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online, false) }} online
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline
</template>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }"
>
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<template v-if="instancePath">
<router-link
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instancePath}`"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
size="16px"
:tint-by="instancePath"
class="shrink-0"
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
</template>
</div>
</div>
<div
class="font-semibold flex items-center gap-1 justify-center text-center"
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
>
<template v-if="world.type === 'server'">
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<div
v-else-if="renderedMotd"
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
v-html="renderedMotd"
/>
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
{{ formatMessage(messages.cantConnect) }}
</div>
<div v-else class="font-normal font-minecraft text-secondary leading-5">
{{ formatMessage(messages.aMinecraftServer) }}
</div>
</template>
<template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-if="world.hardcore">
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(messages.hardcore) }}
</template>
<template v-else>
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
</template>
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus">
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
serverIncompatible
? 'Server is incompatible'
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-instance',
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{
id: 'open-instance',
shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
},
{
id: 'refresh',
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
divider: true,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instancePath,
action: () => {
set_world_display_status(
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
divider: true,
shown: !instancePath,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>
<style scoped lang="scss">
.world-item-highlighted {
position: relative;
animation: fade-highlight 4s ease-out;
filter: brightness(1);
&::before {
@apply rounded-xl inset-0 absolute;
animation: fade-opacity 4s ease-out;
content: '';
box-shadow: 0 0 8px 2px var(--color-brand);
border: 1.5px solid var(--color-brand);
opacity: 0;
}
}
@keyframes fade-highlight {
0% {
filter: brightness(1.25);
}
75% {
filter: brightness(1.25);
}
100% {
filter: brightness(1);
}
}
@keyframes fade-opacity {
0% {
opacity: 0.5;
}
75% {
opacity: 0.5;
}
100% {
opacity: 0;
}
}
.light-mode .motd-renderer {
filter: brightness(0.75);
}
</style>

View File

@@ -1,115 +0,0 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld, play: boolean]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref()
const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled')
async function addServer(play: boolean) {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
const index =
(await add_server_to_profile(
props.instance.path,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)) ?? 0
emit(
'submit',
{
name: serverName,
type: 'server',
index,
address: address.value,
pack_status: resourcePackStatus,
},
play,
)
hide()
}
function show() {
name.value = ''
address.value = ''
resourcePack.value = 'enabled'
modal.value.show()
}
function hide() {
modal.value.hide()
}
const messages = defineMessages({
title: {
id: 'instance.add-server.title',
defaultMessage: 'Add a server',
},
addServer: {
id: 'instance.add-server.add-server',
defaultMessage: 'Add server',
},
addAndPlay: {
id: 'instance.add-server.add-and-play',
defaultMessage: 'Add and play',
},
})
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceModalTitlePrefix :instance="instance" />
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -1,118 +0,0 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import {
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
} from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref<string>('')
const address = ref<string>('')
const resourcePack = ref<ServerPackStatus>('enabled')
const index = ref<number>(0)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
await edit_server_in_profile(
props.instance.path,
index.value,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'server',
address.value,
newDisplayStatus.value,
).catch(handleError)
}
emit('submit', {
name: serverName,
type: 'server',
index: index.value,
address: address.value,
pack_status: resourcePackStatus,
display_status: newDisplayStatus.value,
})
hide()
}
function show(server: ServerWorld) {
name.value = server.name
address.value = server.address
resourcePack.value = server.pack_status
index.value = server.index
displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden'
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const titleMessage = defineMessage({
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const icon = ref()
const name = ref()
const path = ref()
const removeIcon = ref(false)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError)
}
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'singleplayer',
path.value,
newDisplayStatus.value,
)
}
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
hide()
}
function show(world: SingleplayerWorld) {
name.value = world.name
path.value = world.path
icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const messages = defineMessages({
title: {
id: 'instance.edit-world.title',
defaultMessage: 'Edit world',
},
name: {
id: 'instance.edit-world.name',
defaultMessage: 'Name',
},
placeholderName: {
id: 'instance.edit-world.placeholder-name',
defaultMessage: 'Minecraft World',
},
resetIcon: {
id: 'instance.edit-world.reset-icon',
defaultMessage: 'Reset icon',
},
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
</template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`,
})
const label = computed(() => formatMessage(labelMessage))
</script>
<template>
<Checkbox v-model="value" :label="label" />
</template>

View File

@@ -1,86 +0,0 @@
<script setup lang="ts">
import { TeleportDropdownMenu } from '@modrinth/ui'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const name = defineModel<string>('name')
const address = defineModel<string>('address')
const resourcePack = defineModel<ServerPackStatus>('resourcePack')
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
enabled: {
id: 'instance.add-server.resource-pack.enabled',
defaultMessage: 'Enabled',
},
prompt: {
id: 'instance.add-server.resource-pack.prompt',
defaultMessage: 'Prompt',
},
disabled: {
id: 'instance.add-server.resource-pack.disabled',
defaultMessage: 'Disabled',
},
})
const messages = defineMessages({
name: {
id: 'instance.server-modal.name',
defaultMessage: 'Name',
},
address: {
id: 'instance.server-modal.address',
defaultMessage: 'Address',
},
resourcePack: {
id: 'instance.server-modal.resource-pack',
defaultMessage: 'Resource pack',
},
placeholderName: {
id: 'instance.server-modal.placeholder-name',
defaultMessage: 'Minecraft Server',
},
})
defineExpose({ resourcePackOptions })
</script>
<template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.address) }}
</h2>
<input
v-model="address"
type="text"
placeholder="example.modrinth.gg"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.resourcePack) }}
</h2>
<div>
<TeleportDropdownMenu
v-model="resourcePack"
:options="resourcePackOptions"
name="Server resource pack"
:display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
"
/>
</div>
</div>
</template>

View File

@@ -1,9 +1,8 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
}

View File

@@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
profile_path: relative path toprofile_listener profile (used for path identification)
profile_path: relative path to profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}

View File

@@ -7,13 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
export async function install(projectId, versionId, packTitle, iconUrl) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -30,23 +24,12 @@ export async function create_profile_and_install(
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title,
}
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
}
// Installs pack from a path
export async function create_profile_and_install_from_file(path) {
export async function install_from_file(path) {
const location = {
type: 'fromFile',
path: path,

View File

@@ -4,8 +4,6 @@
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js'
/// Add instance
/*
@@ -188,17 +186,3 @@ export async function edit(path, editProfile) {
export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}
export async function finish_install(instance) {
if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data
await install_to_existing_profile(
linkedData.project_id,
linkedData.version_id,
instance.name,
instance.path,
).catch(handleError)
} else {
await install(instance.path, false).catch(handleError)
}
}

View File

@@ -0,0 +1,43 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -1,78 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -32,12 +32,7 @@ type GameInstance = {
hooks: Hooks
}
type InstallStage =
| 'installed'
| 'minecraft_installing'
| 'pack_installed'
| 'pack_installing'
| 'not_installed'
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
type LinkedData = {
project_id: ModrinthId
@@ -48,32 +43,6 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = {
maximum: number
}
@@ -114,7 +83,6 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean

View File

@@ -37,13 +37,6 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@@ -56,7 +49,3 @@ export const releaseColor = (releaseType) => {
return ''
}
}
export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}

View File

@@ -1,327 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { get_full_path } from '@/helpers/profile'
import { openPath } from '@/helpers/utils'
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
import dayjs from 'dayjs'
import type { GameVersion } from '@modrinth/ui'
type BaseWorld = {
name: string
last_played?: string
icon?: string
display_status: DisplayStatus
type: WorldType
}
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer'
path: string
game_mode: SingleplayerGameMode
hardcore: boolean
locked: boolean
}
export type ServerWorld = BaseWorld & {
type: 'server'
index: number
address: string
pack_status: ServerPackStatus
}
export type World = SingleplayerWorld | ServerWorld
export type WorldWithProfile = {
profile: string
} & World
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
export type ServerStatus = {
// https://minecraft.wiki/w/Text_component_format
description?: string | Chat
players?: {
max: number
online: number
sample: { name: string; id: string }[]
}
version?: {
name: string
protocol: number
}
favicon?: string
enforces_secure_chat: boolean
ping?: number
}
export interface Chat {
text: string
bold: boolean
italic: boolean
underlined: boolean
strikethrough: boolean
obfuscated: boolean
color?: string
extra: Chat[]
}
export type ServerData = {
refreshing: boolean
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
}
export async function get_profile_worlds(path: string): Promise<World[]> {
return await invoke('plugin:worlds|get_profile_worlds', { path })
}
export async function get_singleplayer_world(
instance: string,
world: string,
): Promise<SingleplayerWorld> {
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
}
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world(
instance: string,
world: string,
newName: string,
): Promise<void> {
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
}
export async function reset_world_icon(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
}
export async function backup_world(instance: string, world: string): Promise<number> {
return await invoke('plugin:worlds|backup_world', { instance, world })
}
export async function delete_world(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|delete_world', { instance, world })
}
export async function add_server_to_profile(
path: string,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
}
export async function edit_server_in_profile(
path: string,
index: number,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
index,
name,
address,
packStatus,
})
}
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
}
export async function start_join_server(path: string, address: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_server', { path, address })
}
export async function showWorldInFolder(instancePath: string, worldPath: string) {
const fullPath = await get_full_path(instancePath)
return await openPath(fullPath + '/saves/' + worldPath)
}
export function getWorldIdentifier(world: World) {
return world.type === 'singleplayer' ? world.path : world.address
}
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
if (!a.last_played) {
return 1
}
if (!b.last_played) {
return -1
}
return dayjs(b.last_played).diff(dayjs(a.last_played))
})
}
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
return world.type === 'singleplayer'
}
export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
address: string,
): Promise<void> {
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => {
serverData.refreshing = false
})
}
export async function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
serverData[server.address] = {
refreshing: true,
}
} else {
serverData[server.address].refreshing = true
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
)
}
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) {
worlds[index] = newWorld
} else {
console.info(`Adding new world at path: ${worldPath}.`)
worlds.push(newWorld)
}
sortWorlds(worlds)
}
export async function handleDefaultProfileUpdateEvent(
worlds: World[],
instancePath: string,
e: ProfileEvent,
) {
if (e.event === 'world_updated') {
await refreshWorld(worlds, instancePath, e.world)
}
if (e.event === 'server_joined') {
const world = worlds.find(
(w) =>
w.type === 'server' &&
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
)
if (world) {
world.last_played = e.timestamp
sortWorlds(worlds)
} else {
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
}
}
}
export async function refreshWorlds(instancePath: string): Promise<World[]> {
const worlds = await get_profile_worlds(instancePath).catch((err) => {
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
})
if (worlds) {
sortWorlds(worlds)
}
return worlds ?? []
}
const FIRST_QUICK_PLAY_VERSION = '23w14a'
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}
export type ProfileEvent = { profile_path_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)

View File

@@ -20,60 +20,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
"instance.add-server.add-server": {
"message": "Add server"
},
"instance.add-server.resource-pack.disabled": {
"message": "Disabled"
},
"instance.add-server.resource-pack.enabled": {
"message": "Enabled"
},
"instance.add-server.resource-pack.prompt": {
"message": "Prompt"
},
"instance.add-server.title": {
"message": "Add a server"
},
"instance.edit-server.title": {
"message": "Edit server"
},
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": {
"message": "Name"
},
"instance.edit-world.placeholder-name": {
"message": "Minecraft World"
},
"instance.edit-world.reset-icon": {
"message": "Reset icon"
},
"instance.edit-world.title": {
"message": "Edit world"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"instance.server-modal.address": {
"message": "Address"
},
"instance.server-modal.name": {
"message": "Name"
},
"instance.server-modal.placeholder-name": {
"message": "Minecraft Server"
},
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -356,48 +305,6 @@
"instance.settings.title": {
"message": "Settings"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
"instance.worlds.cant_connect": {
"message": "Can't connect to server"
},
"instance.worlds.copy_address": {
"message": "Copy address"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": {
"message": "Available"
},
"instance.worlds.game_already_open": {
"message": "Instance is already open"
},
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": {
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Singleplayer"
},
"instance.worlds.view_instance": {
"message": "View instance"
},
"instance.worlds.world_in_use": {
"message": "World is in use"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},

View File

@@ -6,7 +6,6 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@@ -25,13 +24,6 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
const vueScan = new VueScanPlugin({
enabled: false, // Enable or disable the tracker
showOverlay: true, // Show overlay to visualize renders
log: false, // Log render events to the console
playSound: false, // Play sound on each render
})
const pinia = createPinia()
let app = createApp(App)
@@ -43,7 +35,6 @@ Sentry.init({
tracesSampleRate: 0.1,
})
app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {

View File

@@ -356,6 +356,12 @@ const messages = defineMessages({
const options = ref(null)
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
{
name: 'install',
},
{
type: 'divider',
},
{
name: 'open_link',
},

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup>
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
@@ -8,32 +8,19 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
const featuredModpacks = ref({})
const featuredMods = ref({})
const filter = ref('')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
const instances = ref<GameInstance[]>([])
const recentInstances = ref([])
const featuredModpacks = ref<SearchResult[]>([])
const featuredMods = ref<SearchResult[]>([])
const installedModpacksFilter = ref('')
const recentInstances = computed(() =>
instances.value
.filter((x) => x.last_played)
.slice()
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
)
const hasFeaturedProjects = computed(
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
)
const offline = ref<boolean>(!navigator.onLine)
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
@@ -41,21 +28,34 @@ window.addEventListener('online', () => {
offline.value = false
})
async function fetchInstances() {
instances.value = await list().catch(handleError)
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles
.filter((x) => x.last_played)
.sort((a, b) => {
const dateA = dayjs(a.last_played)
const dateB = dayjs(b.last_played)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
const filters = []
for (const instance of instances.value) {
for (const instance of profiles) {
if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
}
}
installedModpacksFilter.value = filters.join(' AND ')
filter.value = filters.join(' AND ')
}
async function fetchFeaturedModpacks() {
const getFeaturedModpacks = async () => {
const response = await get_search_results(
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
)
if (response) {
@@ -64,8 +64,7 @@ async function fetchFeaturedModpacks() {
featuredModpacks.value = []
}
}
async function fetchFeaturedMods() {
const getFeaturedMods = async () => {
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
if (response) {
@@ -75,21 +74,27 @@ async function fetchFeaturedMods() {
}
}
async function refreshFeaturedProjects() {
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
}
await getInstances()
await fetchInstances()
await refreshFeaturedProjects()
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
const unlistenProfile = await profile_listener(async (e) => {
await fetchInstances()
await getInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects()
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
}
})
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
const total = computed(() => {
return (
(recentInstances.value?.length ?? 0) +
(featuredModpacks.value?.length ?? 0) +
(featuredMods.value?.length ?? 0)
)
})
onUnmounted(() => {
unlistenProfile()
})
@@ -99,10 +104,17 @@ onUnmounted(() => {
<div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay
v-if="hasFeaturedProjects"
v-if="total > 0"
:instances="[
{
label: 'Recently played',
route: '/library',
instances: recentInstances,
instance: true,
downloaded: true,
compact: true,
},
{
label: 'Discover a modpack',
route: '/browse/modpack',

View File

@@ -1,4 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>

View File

@@ -1,5 +1,4 @@
import Index from './Index.vue'
import Browse from './Browse.vue'
import Worlds from './Worlds.vue'
export { Index, Browse, Worlds }
export { Index, Browse }

View File

@@ -1,197 +1,174 @@
<template>
<div>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="brand"
size="large"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
</template>
<script setup>
import {
Avatar,
ButtonStyled,
ContentPageHeader,
LoadingIndicator,
ButtonStyled,
OverflowMenu,
LoadingIndicator,
} from '@modrinth/ui'
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon,
PlayIcon,
StopCircleIcon,
EditIcon,
FolderOpenIcon,
ClipboardCopyIcon,
PlusIcon,
ExternalIcon,
HashIcon,
GlobeIcon,
EyeIcon,
XIcon,
CheckCircleIcon,
UpdatedIcon,
MoreVerticalIcon,
GameIcon,
TimerIcon,
} from '@modrinth/assets'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { computed, onUnmounted, ref, watch } from 'vue'
import { ref, onUnmounted, computed, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -246,10 +223,6 @@ async function fetchInstance() {
})
}
await updatePlayState()
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = runningProcesses.length > 0
@@ -265,20 +238,14 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
href: `/instance/${encodeURIComponent(route.params.id)}`,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
},
])
@@ -327,10 +294,6 @@ const stopInstance = async (context) => {
})
}
const repairInstance = async () => {
await finish_install(instance.value)
}
const handleRightClick = (event) => {
const baseOptions = [
{ name: 'add_content' },

View File

@@ -117,37 +117,15 @@ const route = useRoute()
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
required: true,
},
offline: {
type: Boolean,
default() {
return false
},
default: false,
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
default: false,
},
})

View File

@@ -1,252 +1,260 @@
<template>
<div>
<template v-if="projects?.length > 0">
<div class="flex items-center gap-2 mb-4">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
class="text-input search-input"
autocomplete="off"
/>
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<AddContentButton :instance="instance" />
</div>
<div class="flex items-center justify-between">
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in filterOptions"
:key="`content-filter-${filter.id}`"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
{{ filter.formattedName }}
</button>
</div>
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
<template v-if="projects?.length > 0">
<div class="flex items-center gap-2 mb-4">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
class="text-input search-input"
autocomplete="off"
/>
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<AddContentButton :instance="instance" />
</div>
<div class="flex items-center justify-between">
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in filterOptions"
:key="filter"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
{{ filter.formattedName }}
</button>
</div>
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
<ContentListPanel
v-model="selectedFiles"
:locked="isPackLocked"
:items="
search.map((x) => {
const item: ContentItem<any> = {
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon ?? undefined,
title: x.name,
data: x,
<ContentListPanel
v-model="selectedFiles"
:locked="isPackLocked"
:items="
search.map((x) => {
const item: ContentItem<any> = {
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon,
title: x.name,
data: x,
}
if (x.version) {
item.version = x.version
item.versionId = x.version
}
if (x.id) {
item.project = {
id: x.id,
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
linkProps: {},
}
}
if (x.version) {
item.version = x.version
item.versionId = x.version
if (x.author) {
item.creator = {
name: x.author,
type: 'user',
id: x.author,
link: 'https://modrinth.com/user/' + x.author,
linkProps: { target: '_blank' },
}
}
if (x.id) {
item.project = {
id: x.id,
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
linkProps: {},
}
}
if (x.author) {
item.creator = {
name: x.author.name,
type: x.author.type,
id: x.author.slug,
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
linkProps: { target: '_blank' },
}
}
return item
})
"
:sort-column="sortColumn"
:sort-ascending="ascending"
:update-sort="sortProjects"
:current-page="currentPage"
>
<template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2">
<ButtonStyled
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button @click="updateSelected()"><DownloadIcon /> Update</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'share-names',
action: () => shareNames(),
},
{
id: 'share-file-names',
action: () => shareFileNames(),
},
{
id: 'share-urls',
action: () => shareUrls(),
},
{
id: 'share-markdown',
action: () => shareMarkdown(),
},
]"
>
<ShareIcon /> Share <DropdownIcon />
<template #share-names> <TextInputIcon /> Project names </template>
<template #share-file-names> <FileIcon /> File names </template>
<template #share-urls> <LinkIcon /> Project links </template>
<template #share-markdown> <CodeIcon /> Markdown links </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
<button @click="disableAll()"><SlashIcon /> Disable</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
</ButtonStyled>
</div>
</template>
<template #header-actions>
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<UpdatedIcon />
Refresh
</button>
</ButtonStyled>
return item
})
"
:sort-column="sortColumn"
:sort-ascending="ascending"
:update-sort="sortProjects"
:current-page="currentPage"
>
<template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2">
<ButtonStyled
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
@click="updateAll"
>
<button class="w-max"><DownloadIcon /> Update all</button>
</ButtonStyled>
<ButtonStyled
v-if="canUpdatePack"
type="transparent"
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
<DownloadIcon /> Update pack
</button>
<button @click="updateSelected()"><DownloadIcon /> Update</button>
</ButtonStyled>
</template>
<template #actions="{ item }">
<ButtonStyled
v-if="!isPackLocked && (item.data as any).outdated"
type="transparent"
color="brand"
circular
>
<button
v-tooltip="`Update`"
:disabled="(item.data as ProjectListEntry).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<Toggle
class="!mx-2"
:model-value="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)"
/>
<ButtonStyled type="transparent" circular>
<button v-tooltip="'Remove'" @click="removeMod(item)">
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'show-file',
action: () => highlightModInProfile(instance.path, item.path),
id: 'share-names',
action: () => shareNames(),
},
{
id: 'copy-link',
shown: item.data !== undefined && item.data.slug !== undefined,
action: () => copyModLink(item),
id: 'share-file-names',
action: () => shareFileNames(),
},
{
id: 'share-urls',
action: () => shareUrls(),
},
{
id: 'share-markdown',
action: () => shareMarkdown(),
},
]"
direction="left"
>
<MoreVerticalIcon />
<template #show-file> <ExternalIcon /> Show file </template>
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
<ShareIcon /> Share <DropdownIcon />
<template #share-names> <TextInputIcon /> Project names </template>
<template #share-file-names> <FileIcon /> File names </template>
<template #share-urls> <LinkIcon /> Project links </template>
<template #share-markdown> <CodeIcon /> Markdown links </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentListPanel>
<div class="flex justify-end mt-4">
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
</template>
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
<RadialHeader class="">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
<span class="text-contrast font-bold text-xl"
>You haven't added any content to this instance yet.</span
>
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
<button @click="disableAll()"><SlashIcon /> Disable</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
</ButtonStyled>
</div>
</RadialHeader>
<div class="flex mt-4 mx-auto">
<AddContentButton :instance="instance" />
</template>
<template #header-actions>
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<UpdatedIcon />
Refresh
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
@click="updateAll"
>
<button class="w-max"><DownloadIcon /> Update all</button>
</ButtonStyled>
<ButtonStyled
v-if="canUpdatePack"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<DownloadIcon /> Update pack
</button>
</ButtonStyled>
</template>
<template #actions="{ item }">
<ButtonStyled
v-if="!isPackLocked && (item.data as any).outdated"
type="transparent"
color="brand"
circular
>
<button
v-tooltip="`Update`"
:disabled="(item.data as any).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<ButtonStyled type="transparent" circular>
<button
v-tooltip="item.disabled ? `Enable` : `Disable`"
@click="toggleDisableMod(item.data)"
>
<CheckCircleIcon v-if="item.disabled" />
<SlashIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'show-file',
action: () => highlightModInProfile(instance.path, item.path),
},
{
id: 'copy-link',
shown: item.data !== undefined && item.data.slug !== undefined,
action: () => copyModLink(item),
},
{
divider: true,
},
{
id: 'remove',
color: 'red',
action: () => removeMod(item),
},
]"
direction="left"
>
<MoreVerticalIcon />
<template #show-file> <ExternalIcon /> Show file </template>
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
<template v-if="item.disabled" #toggle> <CheckCircleIcon /> Enable </template>
<template v-else #toggle> <SlashIcon /> Disable </template>
<template #remove> <TrashIcon /> Remove </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentListPanel>
<div class="flex justify-end mt-4">
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
</template>
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
<div class="top-box w-full">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
<span class="text-contrast font-bold text-xl"
>You haven't added any content to this instance yet.</span
>
</div>
</div>
<ShareModalWrapper
ref="shareModal"
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
:open-in-new-tab="false"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
<div class="top-box-divider"></div>
<div class="flex items-center gap-6 py-4">
<AddContentButton :instance="instance" />
</div>
</div>
<ShareModalWrapper
ref="shareModal"
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
:open-in-new-tab="false"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup lang="ts">
import {
@@ -267,16 +275,7 @@ import {
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
ContentListPanel,
OverflowMenu,
Pagination,
RadialHeader,
Toggle,
} from '@modrinth/ui'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { Button, ButtonStyled, ContentListPanel, OverflowMenu, Pagination } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
@@ -306,42 +305,31 @@ import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
type ProjectListEntryAuthor = {
name: string
slug: string
type: 'user' | 'organization'
}
type ProjectListEntry = {
path: string
name: string
slug?: string
author: ProjectListEntryAuthor | null
version: string | null
file_name: string
icon: string | undefined
disabled: boolean
updateVersion?: string
outdated: boolean
updated: dayjs.Dayjs
project_type: string
id?: string
updating?: boolean
selected?: boolean
}
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
})
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
@@ -352,21 +340,18 @@ const canUpdatePack = computed(() => {
})
const exportModal = ref(null)
const projects = ref<ProjectListEntry[]>([])
const selectedFiles = ref<string[]>([])
const projects = ref([])
const selectedFiles = ref([])
const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
)
const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const newProjects: ProjectListEntry[] = []
const initProjects = async (cacheBehaviour?) => {
const newProjects = []
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
string,
ContentFile
>
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
const fetchProjects = []
const fetchVersions = []
@@ -378,21 +363,21 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
}
const [modrinthProjects, modrinthVersions] = await Promise.all([
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
await get_project_many(fetchProjects).catch(handleError),
await get_version_many(fetchVersions).catch(handleError),
])
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
(await get_organization_many(
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
await get_organization_many(
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
).catch(handleError)) as Organization[],
).catch(handleError),
])
for (const [path, file] of Object.entries(profileProjects)) {
if (file.metadata) {
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
if (project && version) {
const org = project.organization
@@ -401,29 +386,21 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let author: ProjectListEntryAuthor | null = null
let owner
if (org) {
author = {
name: org.name,
slug: org.slug,
type: 'organization',
}
owner = org.name
} else if (team) {
const teamMember = team.find((x) => x.is_owner)
if (teamMember) {
author = {
name: teamMember.user.username,
slug: teamMember.user.username,
type: 'user',
}
}
owner = team.find((x) => x.is_owner).user.username
} else {
owner = null
}
newProjects.push({
path,
name: project.title,
slug: project.slug,
author,
author: owner,
version: version.version_number,
file_name: file.file_name,
icon: project.icon_url,
@@ -442,10 +419,10 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
newProjects.push({
path,
name: file.file_name.replace('.disabled', ''),
author: null,
author: '',
version: null,
file_name: file.file_name,
icon: undefined,
icon: null,
disabled: file.file_name.endsWith('.disabled'),
outdated: false,
updated: dayjs(0),
@@ -453,7 +430,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
})
}
projects.value = newProjects ?? []
projects.value = newProjects
const newSelectionMap = new Map()
for (const project of projects.value) {
@@ -469,7 +446,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
}
await initProjects()
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl()
@@ -485,16 +462,12 @@ const messages = defineMessages({
id: 'instance.filter.updates-available',
defaultMessage: 'Updates available',
},
disabledFilter: {
id: 'instance.filter.disabled',
defaultMessage: 'Disabled projects',
},
})
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = []
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
const frequency = projects.value.reduce((map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
}, {})
@@ -515,30 +488,19 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
})
}
if (projects.value.some((m) => m.disabled)) {
options.push({
id: 'disabled',
formattedName: formatMessage(messages.disabledFilter),
})
}
return options
})
const selectedFilters = ref<string[]>([])
const selectedFilters = ref([])
const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled')
const typeFilters = selectedFilters.value.filter(
(filter) => filter !== 'updates' && filter !== 'disabled',
)
const typeFilters = selectedFilters.value.filter((filter) => filter !== 'updates')
return projects.value.filter((project) => {
return (
(typeFilters.length === 0 || typeFilters.includes(project.project_type)) &&
(!updatesFilter || project.outdated) &&
(!disabledFilter || project.disabled)
(!updatesFilter || project.outdated)
)
})
})
@@ -552,7 +514,7 @@ watch(filterOptions, () => {
}
})
function toggleArray<T>(array: T[], value: T) {
function toggleArray(array, value) {
if (array.includes(value)) {
array.splice(array.indexOf(value), 1)
} else {
@@ -562,7 +524,7 @@ function toggleArray<T>(array: T[], value: T) {
const searchFilter = ref('')
const selectAll = ref(false)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
const shareModal = ref(null)
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
@@ -603,7 +565,7 @@ const search = computed(() => {
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
const sortProjects = (filter: string) => {
const sortProjects = (filter) => {
if (sortColumn.value === filter) {
ascending.value = !ascending.value
} else {
@@ -621,7 +583,7 @@ const updateAll = async () => {
}
}
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
const paths = await update_all(props.instance.path).catch(handleError)
for (const [oldVal, newVal] of Object.entries(paths)) {
const index = projects.value.findIndex((x) => x.path === oldVal)
@@ -630,7 +592,7 @@ const updateAll = async () => {
if (projects.value[index].updateVersion) {
projects.value[index].version = projects.value[index].updateVersion.version_number
projects.value[index].updateVersion = undefined
projects.value[index].updateVersion = null
}
}
for (const project of setProjects) {
@@ -645,15 +607,15 @@ const updateAll = async () => {
})
}
const updateProject = async (mod: ProjectListEntry) => {
const updateProject = async (mod) => {
mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false
mod.outdated = false
mod.version = mod.updateVersion?.version_number
mod.updateVersion = undefined
mod.version = mod.updateVersion.version_number
mod.updateVersion = null
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
@@ -664,15 +626,15 @@ const updateProject = async (mod: ProjectListEntry) => {
})
}
const locks: Record<string, string | null> = {}
const locks = {}
const toggleDisableMod = async (mod: ProjectListEntry) => {
const toggleDisableMod = async (mod) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
const lock = locks[mod.file_name]
while (lock) {
await new Promise((resolve) => {
setTimeout((value: unknown) => resolve(value), 100)
setTimeout((_) => resolve(), 100)
})
}
@@ -697,20 +659,20 @@ const toggleDisableMod = async (mod: ProjectListEntry) => {
locks[mod.file_name] = null
}
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
const removeMod = async (mod) => {
await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.data.id,
name: mod.data.name,
project_type: mod.data.project_type,
id: mod.id,
name: mod.name,
project_type: mod.project_type,
})
}
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
const copyModLink = async (mod) => {
await navigator.clipboard.writeText(
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
)
@@ -725,15 +687,15 @@ const deleteSelected = async () => {
}
const shareNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
}
const shareFileNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
}
const shareUrls = async () => {
await shareModal.value?.show(
await shareModal.value.show(
functionValues.value
.filter((x) => x.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
@@ -742,7 +704,7 @@ const shareUrls = async () => {
}
const shareMarkdown = async () => {
await shareModal.value?.show(
await shareModal.value.show(
functionValues.value
.map((x) => {
if (x.slug) {
@@ -807,17 +769,15 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
await initProjects()
})
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
},
)
const unlistenProfiles = await profile_listener(async (event) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
})
onUnmounted(() => {
unlisten()

View File

@@ -1,15 +0,0 @@
<template>{{ instance.name }} overview</template>
<script setup lang="ts">
import type { GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
</script>

View File

@@ -1,465 +0,0 @@
<template>
<AddServerModal
ref="addServerModal"
:instance="instance"
@submit="
(server, start) => {
addServer(server)
if (start) {
joinWorld(server)
}
}
"
/>
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
<ConfirmModalWrapper
ref="removeServerModal"
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
:markdown="false"
@proceed="proceedRemoveServer"
/>
<ConfirmModalWrapper
ref="deleteWorldModal"
:title="`Are you sure you want to permanently delete this world?`"
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
@proceed="proceedDeleteWorld"
/>
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2 items-center">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search worlds...`"
class="text-input search-input"
autocomplete="off"
/>
<Button v-if="searchFilter" class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<ButtonStyled>
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<template v-if="refreshingAll">
<SpinnerIcon class="animate-spin" />
Refreshing...
</template>
<template v-else>
<UpdatedIcon />
Refresh
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="addServerModal?.show()">
<PlusIcon />
Add a server
</button>
</ButtonStyled>
</div>
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
<div class="flex flex-col w-full gap-2">
<WorldItem
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-quick-play="supportsQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
:starting-instance="startingInstance"
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
:rendered-motd="
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
"
@delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
</div>
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
<RadialHeader class="">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
</div>
</RadialHeader>
<div class="flex gap-2 mt-4 mx-auto">
<ButtonStyled>
<button @click="addServerModal?.show()">
<PlusIcon aria-hidden="true" />
Add a server
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<template v-if="refreshingAll">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
Refreshing...
</template>
<template v-else>
<UpdatedIcon aria-hidden="true" />
Refresh
</template>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import {
Button,
ButtonStyled,
RadialHeader,
FilterBar,
type FilterBarOption,
type GameVersion,
GAME_MODES,
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type SingleplayerWorld,
type World,
type ServerWorld,
type ServerData,
type ProfileEvent,
get_profile_protocol_version,
remove_server_from_profile,
delete_world,
start_join_server,
start_join_singleplayer_world,
getWorldIdentifier,
refreshServerData,
refreshWorld,
sortWorlds,
refreshServers,
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { handleError } from '@/store/notifications'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
import { profile_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import { defineMessages } from '@vintl/vintl'
const route = useRoute()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const serverToRemove = ref<ServerWorld>()
const worldToDelete = ref<SingleplayerWorld>()
const emit = defineEmits<{
(event: 'play', world: World): void
(event: 'stop'): void
}>()
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu> | null
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
emit('play', world)
}
const filters = ref<string[]>([])
const searchFilter = ref('')
const refreshingAll = ref(false)
const hadNoWorlds = ref(true)
const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
if (e.event === 'servers_updated') {
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
})
await refreshAllWorlds()
async function refreshServer(address: string) {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
await refreshServerData(serverData.value[address], protocolVersion.value, address)
}
async function refreshAllWorlds() {
if (refreshingAll.value) {
console.log(`Already refreshing, cancelling refresh.`)
return
}
refreshingAll.value = true
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0
if (hadNoWorlds.value && hasNoWorlds) {
setTimeout(() => {
refreshingAll.value = false
}, 1000)
} else {
refreshingAll.value = false
}
hadNoWorlds.value = hasNoWorlds
}
async function addServer(server: ServerWorld) {
worlds.value.push(server)
sortWorlds(worlds.value)
await refreshServer(server.address)
}
async function editServer(server: ServerWorld) {
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
if (index !== -1) {
const oldServer = worlds.value[index] as ServerWorld
worlds.value[index] = server
sortWorlds(worlds.value)
if (oldServer.address !== server.address) {
await refreshServer(server.address)
}
} else {
handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds()
}
}
async function removeServer(server: ServerWorld) {
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
}
async function editWorld(path: string, name: string, removeIcon: boolean) {
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
if (world) {
world.name = name
if (removeIcon) {
world.icon = undefined
}
sortWorlds(worlds.value)
} else {
handleError(`Error finding world in list, refreshing all worlds`)
await refreshAllWorlds()
}
}
async function deleteWorld(world: SingleplayerWorld) {
await delete_world(instance.value.path, world.path).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
}
function handleJoinError(err: unknown) {
handleError(err)
startingInstance.value = false
worldPlaying.value = undefined
}
async function joinWorld(world: World) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
}
play(world)
startingInstance.value = false
}
watch(
() => playing.value,
(playing) => {
if (!playing) {
worldPlaying.value = undefined
setTimeout(async () => {
for (const world of worlds.value) {
if (world.type === 'singleplayer' && world.locked) {
await refreshWorld(worlds.value, instance.value.path, world.path)
}
}
}, 1000)
}
},
)
function worldsMatch(world: World, other: World | undefined) {
if (world.type === 'server' && other?.type === 'server') {
return world.address === other.address
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
return world.path === other.path
}
return false
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const filterOptions = computed(() => {
const options: FilterBarOption[] = []
const hasServer = worlds.value.some((x) => x.type === 'server')
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({
id: 'singleplayer',
message: messages.singleplayer,
})
options.push({
id: 'server',
message: messages.server,
})
}
if (hasServer) {
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if (
worlds.value.some(
(x) =>
x.type === 'server' &&
!serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing),
)
) {
options.push({
id: 'available',
message: messages.available,
})
}
}
return options
})
const filteredWorlds = computed(() =>
worlds.value.filter((x) => {
const availableFilter = filters.value.includes('available')
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
return (
(!typeFilter || filters.value.includes(x.type)) &&
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
)
}),
)
const highlightedWorld = ref(route.query.highlight)
function promptToRemoveWorld(world: World): boolean {
if (world.type === 'server') {
serverToRemove.value = world
removeServerModal.value?.show()
return !!removeServerModal.value
} else {
worldToDelete.value = world
deleteWorldModal.value?.show()
return !!deleteWorldModal.value
}
}
async function proceedRemoveServer() {
if (!serverToRemove.value) {
handleError(`Error removing server, no server marked for removal.`)
return
}
await removeServer(serverToRemove.value)
serverToRemove.value = undefined
}
async function proceedDeleteWorld() {
if (!worldToDelete.value) {
handleError(`Error deleting world, no world marked for removal.`)
return
}
await deleteWorld(worldToDelete.value)
worldToDelete.value = undefined
}
onUnmounted(() => {
unlistenProfile()
})
const messages = defineMessages({
singleplayer: {
id: 'instance.worlds.type.singleplayer',
defaultMessage: 'Singleplayer',
},
server: {
id: 'instance.worlds.type.server',
defaultMessage: 'Server',
},
available: {
id: 'instance.worlds.filter.available',
defaultMessage: 'Available',
},
})
</script>

View File

@@ -1,7 +1,5 @@
import Index from './Index.vue'
import Overview from './Overview.vue'
import Worlds from './Worlds.vue'
import Mods from './Mods.vue'
import Logs from './Logs.vue'
export { Index, Overview, Worlds, Mods, Logs }
export { Index, Mods, Logs }

View File

@@ -31,10 +31,10 @@
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop="() => {}"
@click.stop=""
/>
<div class="floating" @click.stop="() => {}">
<div class="floating" @click.stop="">
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
@@ -99,7 +99,7 @@ import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const props = defineProps({
project: {
type: Object,
default: () => ({}),
default: () => {},
},
})

View File

@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
@@ -170,7 +170,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
@@ -193,11 +192,6 @@ const [allLoaders, allGameVersions] = await Promise.all([
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
if (!project) {
handleError('Error loading project')
return
}
data.value = project
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
@@ -248,9 +242,6 @@ async function install(version) {
installedVersion.value = version
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -18,14 +18,6 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.Worlds,
meta: {
breadcrumb: [{ name: 'Worlds' }],
},
},
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -114,31 +106,13 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
// {
// path: '',
// name: 'Overview',
// component: Instance.Overview,
// meta: {
// useRootContext: true,
// breadcrumb: [{ name: '?Instance' }],
// },
// },
{
path: 'worlds',
name: 'InstanceWorlds',
component: Instance.Worlds,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
breadcrumb: [{ name: '?Instance' }],
},
},
{
@@ -147,7 +121,7 @@ export default new createRouter({
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
breadcrumb: [{ name: '?Instance' }],
},
},
{

View File

@@ -2,14 +2,14 @@ import { defineStore } from 'pinia'
import {
add_project_from_version,
check_installed,
list,
get,
get_projects,
list,
remove_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { install as packInstall } from '@/helpers/pack.js'
import { trackEvent } from '@/helpers/analytics.js'
import dayjs from 'dayjs'
@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
showInstallConfirmModal(project, version_id, onInstall) {
this.installConfirmModal.show(project, version_id, onInstall)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
@@ -41,14 +41,7 @@ export const useInstall = defineStore('installStore', {
},
})
export const install = async (
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -56,13 +49,7 @@ export const install = async (
const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall(
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
).catch(handleError)
await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
trackEvent('PackInstall', {
id: project.id,
@@ -74,7 +61,7 @@ export const install = async (
callback(version)
} else {
const install = useInstall()
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
install.showInstallConfirmModal(project, version, callback)
}
} else {
if (instancePath) {

View File

@@ -1,4 +1,4 @@
import { useTheming } from './theme.ts'
import { useTheming } from './theme'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'

View File

@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true,
selectedTheme: 'dark',
toggleSidebar: false,
devMode: false,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
else console.warn('Selected theme is not present. Check themeOptions.')
this.setThemeClass()
},
setThemeClass() {
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
},
})

View File

@@ -1,70 +0,0 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
toggleSidebar: boolean
devMode: boolean
featureFlags: FeatureFlags
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setThemeClass() {
for (const theme of THEME_OPTIONS) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})

View File

@@ -1,2 +0,0 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -1,14 +1,24 @@
[package]
name = "theseus_playground"
version = "0.0.0"
edition.workspace = true
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
theseus = { workspace = true, features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
enumset.workspace = true
theseus = { path = "../../packages/app-lib", features = ["cli"] }
[lints]
workspace = true
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
url = "2.2"
webbrowser = "0.8.13"
dunce = "1.0.3"
futures = "0.3"
uuid = { version = "1.1", features = ["serde", "v4"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.18"
tracing-error = "0.2.0"

View File

@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast"
"test": "cargo test"
}
}

View File

@@ -3,9 +3,9 @@
windows_subsystem = "windows"
)]
use enumset::EnumSet;
use theseus::prelude::*;
use theseus::worlds::get_recent_worlds;
use theseus::profile::create::profile_create;
// A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -15,7 +15,8 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("URL {}", login.redirect_uri.as_str());
webbrowser::open(login.redirect_uri.as_str())?;
println!("Please enter URL code: ");
let mut input = String::new();
@@ -27,10 +28,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?;
println!(
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
println!("Logged in user {}.", credentials.username);
Ok(credentials)
}
@@ -43,16 +41,54 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
for world in worlds {
println!(
"World: {:?}/{:?} played at {:?}: {:#?}",
world.profile,
world.world.name,
world.world.last_played,
world.world.details
);
if minecraft_auth::users().await?.is_empty() {
println!("No users found, authenticating.");
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users
}
//
// st.settings
// .write()
// .await
// .java_globals
// .insert(JAVA_8_KEY.to_string(), check_jre(path).await?.unwrap());
// Clear profiles
println!("Clearing profiles.");
{
let h = profile::list().await?;
for profile in h.into_iter() {
profile::remove(&profile.path).await?;
}
}
println!("Creating/adding profile.");
let name = "Example".to_string();
let game_version = "1.16.1".to_string();
let modloader = ModLoader::Forge;
let loader_version = "stable".to_string();
let profile_path = profile_create(
name,
game_version,
modloader,
Some(loader_version),
None,
None,
None,
)
.await?;
println!("running");
// Run a profile, running minecraft and store the RwLock to the process
let process = profile::run(&profile_path).await?;
println!("Minecraft UUID: {}", process.uuid);
println!("All running process UUID {:?}", process::get_all().await?);
// hold the lock to the process until it ends
println!("Waiting for process to end...");
process::wait_for(process.uuid).await?;
Ok(())
}

View File

@@ -1,2 +0,0 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -1,51 +1,66 @@
[package]
name = "theseus_gui"
version = "0.9.5"
version = "0.9.0"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
edition.workspace = true
edition = "2021"
build = "build.rs"
[build-dependencies]
tauri-build = { workspace = true, features = ["codegen"] }
tauri-build = { version = "2.0.3", features = ["codegen"] }
[dependencies]
theseus = { workspace = true, features = ["tauri"] }
theseus = { path = "../../packages/app-lib", features = ["tauri"] }
serde_json.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_with = "3.0.0"
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state.workspace = true
tauri-plugin-deep-link.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-single-instance.workspace = true
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state = "2.2.0"
tauri-plugin-deep-link = "2.2.0"
tauri-plugin-os = "2.2.0"
tauri-plugin-opener = "2.2.1"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-updater = { version = "2.3.0" }
tauri-plugin-single-instance = { version = "2.2.0" }
tokio = { workspace = true, features = ["time"] }
thiserror.workspace = true
daedalus.workspace = true
chrono.workspace = true
either.workspace = true
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
futures = "0.3"
daedalus = { path = "../../packages/daedalus" }
chrono = "0.4.26"
url.workspace = true
urlencoding.workspace = true
uuid = { workspace = true, features = ["serde", "v4"] }
dirs = "5.0.1"
tracing.workspace = true
tracing-error.workspace = true
url = "2.2"
uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"
dashmap.workspace = true
paste.workspace = true
enumset = { workspace = true, features = ["serde"] }
tracing = "0.1.37"
tracing-error = "0.2.0"
native-dialog.workspace = true
lazy_static = "1"
once_cell = "1"
dashmap = "6.0.1"
paste = "1.0.15"
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
native-dialog = "0.7.0"
[target.'cfg(not(target_os = "linux"))'.dependencies]
window-shadows = "0.2.1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
[target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true }
tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
[features]
# by default Tauri runs in production mode
@@ -55,6 +70,3 @@ default = ["custom-protocol"]
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
updater = []
[lints]
workspace = true

View File

@@ -151,6 +151,7 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
"profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",
@@ -239,30 +240,6 @@ fn main() {
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"worlds",
InlinedPlugin::new()
.commands(&[
"get_recent_worlds",
"get_profile_worlds",
"get_singleplayer_world",
"set_world_display_status",
"rename_world",
"reset_world_icon",
"backup_world",
"delete_world",
"add_server_to_profile",
"edit_server_in_profile",
"remove_server_from_profile",
"get_profile_protocol_version",
"get_server_status",
"start_join_singleplayer_world",
"start_join_server",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
),
)
.expect("Failed to run tauri-build");

View File

@@ -35,7 +35,6 @@
"tags:default",
"utils:default",
"ads:default",
"friends:default",
"worlds:default"
"friends:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Some files were not shown because too many files have changed in this diff Show More