Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17a66e44f | ||
|
|
871b0ee001 | ||
|
|
adebde714d | ||
|
|
13a56f4200 | ||
|
|
8b94e7dbbe | ||
|
|
0f35a5f707 | ||
|
|
6fb73490d3 | ||
|
|
2dcf26c9d9 | ||
|
|
9ccacba3fd | ||
|
|
54499dd9da | ||
|
|
fce6c60759 | ||
|
|
dcbad9447d | ||
|
|
4abf643852 | ||
|
|
a8d8e1674b | ||
|
|
60f3fd8816 | ||
|
|
c71c220446 | ||
|
|
a95153d0b1 | ||
|
|
853147b04d | ||
|
|
8d61826c5f | ||
|
|
2e92887eb2 | ||
|
|
47bdb1e79c | ||
|
|
d6fdb4f453 | ||
|
|
4ba1ed279f | ||
|
|
afdd091971 | ||
|
|
99e0aa9d14 | ||
|
|
22bc631293 | ||
|
|
3d3682023e | ||
|
|
f253d5529b | ||
|
|
253141b6ff | ||
|
|
946a477397 | ||
|
|
eabac1f11e | ||
|
|
c9a2eeb53d | ||
|
|
b776b99f26 | ||
|
|
daeedc4951 | ||
|
|
79addd4aba | ||
|
|
cadb7bbdcd | ||
|
|
7f3ab9d79b | ||
|
|
037bff2db3 | ||
|
|
1406f7ded3 | ||
|
|
c54acd2e36 | ||
|
|
1b6534ad8c | ||
|
|
2477fc62ca | ||
|
|
3f6678ac2f | ||
|
|
e36329ff0e | ||
|
|
c718d75774 | ||
|
|
544611738d | ||
|
|
98396c6bcd | ||
|
|
403a883e0b | ||
|
|
53c749aaea | ||
|
|
addc31c7c1 | ||
|
|
25978bcd8f | ||
|
|
117eaad9ff | ||
|
|
91e2c369f5 | ||
|
|
ae2992d7f8 | ||
|
|
20d5d2baab | ||
|
|
748d24b264 | ||
|
|
bb0b094002 | ||
|
|
386db4bdb5 | ||
|
|
f98dbf1592 | ||
|
|
48aff7550a | ||
|
|
c000e6a535 | ||
|
|
2ed2791316 | ||
|
|
d422f3aeaf | ||
|
|
a21fe3750b | ||
|
|
8a447f33f6 | ||
|
|
f73bc89f84 | ||
|
|
4f220201b3 | ||
|
|
83ee962c16 | ||
|
|
f7380f1590 | ||
|
|
121da431ed | ||
|
|
b7c16594ce | ||
|
|
fae16a0184 | ||
|
|
6e9485c2f5 | ||
|
|
ed61a5942c | ||
|
|
5a289c3a15 | ||
|
|
91e63fdb2c | ||
|
|
214066ae5f | ||
|
|
2f34faa50f | ||
|
|
a57dfb828e | ||
|
|
eec6b47ee7 | ||
|
|
f776bf8995 | ||
|
|
69a00888ba | ||
|
|
996044bdc2 | ||
|
|
4674f31dd3 | ||
|
|
fe61337a34 | ||
|
|
b633f7cdf0 | ||
|
|
8df1ac8a86 | ||
|
|
a99a0a6e30 | ||
|
|
773cf387a3 | ||
|
|
7414477bbd | ||
|
|
e8f10ab1ef | ||
|
|
e91ba2ae74 | ||
|
|
95a3e00b03 | ||
|
|
f490600ee2 | ||
|
|
b57219f348 | ||
|
|
ead942abb2 | ||
|
|
d2c32ed14f | ||
|
|
4b9b9ebf47 | ||
|
|
457dfd8c30 | ||
|
|
8ec5cf104c | ||
|
|
306328367e | ||
|
|
3901749d93 | ||
|
|
cee0c3bbd4 | ||
|
|
63ca20db04 | ||
|
|
0388e339db | ||
|
|
d4554b0f4c | ||
|
|
d5471bb36c | ||
|
|
f238eeae0e | ||
|
|
ae9a762439 | ||
|
|
8480927b99 | ||
|
|
24f5761317 | ||
|
|
3d003f9b31 | ||
|
|
1b487da5c2 | ||
|
|
b310018c38 | ||
|
|
fbde37f5a0 | ||
|
|
e6f9987694 | ||
|
|
5a08a1ed3a | ||
|
|
e51abeb5ad | ||
|
|
4c13f849eb | ||
|
|
00bfe2de9f | ||
|
|
95b9c044eb | ||
|
|
911a2cb872 | ||
|
|
0a60695dc9 | ||
|
|
643d7276d7 | ||
|
|
e066c6a8d7 | ||
|
|
b5406c2da0 | ||
|
|
300df18aca | ||
|
|
5efbe048eb | ||
|
|
ead5d96b78 | ||
|
|
e646598078 | ||
|
|
f1ff19e4b8 | ||
|
|
34cf186c33 | ||
|
|
81b7219085 | ||
|
|
3cdc4951e0 | ||
|
|
f839796585 | ||
|
|
e25135934f | ||
|
|
02a8004fd5 | ||
|
|
6b1d2e1943 |
No files matched your search
@@ -12,13 +12,18 @@ on:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post changelog comment
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Post or update changelog comment
|
||||
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
||||
|
||||
@@ -22,5 +22,5 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
- run: cargo binstall --no-confirm cargo-shear
|
||||
- run: cargo binstall --no-confirm cargo-shear@1.13.4
|
||||
- run: cargo shear
|
||||
Generated
+50
-2
@@ -1889,6 +1889,28 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
|
||||
dependencies = [
|
||||
"fontconfig-parser",
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -3477,6 +3499,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -5102,6 +5133,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
@@ -5758,6 +5795,15 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
@@ -6795,7 +6841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "1.0.0-beta15"
|
||||
version = "1.0.2-beta7"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
@@ -6899,7 +6945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "1.0.0-beta15"
|
||||
version = "1.0.2-beta7"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"async_zip",
|
||||
@@ -6912,6 +6958,7 @@ dependencies = [
|
||||
"either",
|
||||
"enumset",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"futures",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -6926,6 +6973,7 @@ dependencies = [
|
||||
"path-util",
|
||||
"quartz_nbt",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
|
||||
@@ -44,6 +44,7 @@ encoding_rs = "0.8.35"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
flate2 = "1.1.4"
|
||||
fontdb = "0.24.0"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
@@ -83,6 +84,7 @@ rgb = "0.8.52"
|
||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
rustls-pki-types = "1.15.0"
|
||||
semver = "1.0.28"
|
||||
serde = "1.0.228"
|
||||
serde_bytes = "0.11.19"
|
||||
serde_cbor = "0.11.2"
|
||||
|
||||
@@ -14,6 +14,8 @@ extend-exclude = [
|
||||
"packages/app-lib/src/api/content_search/WikiEntries.txt",
|
||||
# Bundled Monaco editor runtime contains upstream generated assets.
|
||||
"apps/app-frontend/public/monaco/",
|
||||
# Minecraft language-name data (from mcmeta); contains language codes that look like typos.
|
||||
"apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta15",
|
||||
"version": "1.0.2-beta7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,6 +14,9 @@
|
||||
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@dnd-kit/vue": "^0.5.0",
|
||||
"@lobehub/icons-static-svg": "1.94.0",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FlaskConicalIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
SettingsIcon,
|
||||
SpinnerIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -70,6 +72,7 @@ import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
||||
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
||||
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import AnniversaryCelebration from '@/components/ui/AnniversaryCelebration.vue'
|
||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
@@ -92,6 +95,7 @@ import NavRail from '@/components/ui/NavRail.vue'
|
||||
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
|
||||
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
@@ -141,7 +145,7 @@ import {
|
||||
isNetworkMetered,
|
||||
setRestartAfterPendingUpdate,
|
||||
} from '@/helpers/utils.js'
|
||||
import { installUiScale } from '@/helpers/ui-scale'
|
||||
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
|
||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||
import i18n, { resolveInitialLocale } from '@/i18n.config'
|
||||
import {
|
||||
@@ -165,6 +169,7 @@ import { createServerInstall, provideServerInstall } from '@/providers/server-in
|
||||
import { setupProviders } from '@/providers/setup'
|
||||
import { setupAuthProvider } from '@/providers/setup/auth'
|
||||
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
|
||||
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { useError } from '@/store/error.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
@@ -180,6 +185,8 @@ const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-pr
|
||||
const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path)
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
|
||||
setupKeyboardShortcuts()
|
||||
|
||||
const discoverContentPath = computed(() => {
|
||||
const projectType = route.params.projectType
|
||||
if (
|
||||
@@ -313,6 +320,22 @@ const showOnboarding = ref(false)
|
||||
const onboardingMode = ref('main')
|
||||
const onboardingSettings = ref(null)
|
||||
const onboardingReplay = ref(false)
|
||||
|
||||
// Expand the sidebar while the onboarding tour is visible so steps that point
|
||||
// at sidebar entries (account card, announcement, create instance) can find
|
||||
// their targets. The previous state is restored when the tour ends.
|
||||
let sidebarStateBeforeOnboarding: boolean | undefined
|
||||
watch(showOnboarding, (visible) => {
|
||||
if (visible) {
|
||||
sidebarStateBeforeOnboarding = sidebarToggled.value
|
||||
sidebarToggled.value = true
|
||||
setSidebarExpanded(true)
|
||||
} else if (sidebarStateBeforeOnboarding !== undefined) {
|
||||
sidebarToggled.value = sidebarStateBeforeOnboarding
|
||||
setSidebarExpanded(sidebarStateBeforeOnboarding)
|
||||
sidebarStateBeforeOnboarding = undefined
|
||||
}
|
||||
})
|
||||
const nativeDecorations = ref(false)
|
||||
|
||||
const os = ref('')
|
||||
@@ -749,6 +772,10 @@ const messages = defineMessages({
|
||||
id: 'app.navigation.multiplayer',
|
||||
defaultMessage: 'Multiplayer',
|
||||
},
|
||||
screenshots: {
|
||||
id: 'app.navigation.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.navigation.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
@@ -1074,6 +1101,7 @@ async function setupApp() {
|
||||
close_behavior,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
ui_scale,
|
||||
} = initialSettings
|
||||
|
||||
// Initialize locale from saved settings
|
||||
@@ -1104,6 +1132,7 @@ async function setupApp() {
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||
|
||||
setUiScale(ui_scale)
|
||||
themeStore.setThemeState(theme)
|
||||
await initializeSystemAccentColor()
|
||||
themeStore.setAccentColor(accent_color)
|
||||
@@ -1236,7 +1265,7 @@ async function replayOnboarding(mode) {
|
||||
|
||||
async function finishOnboarding() {
|
||||
const wasReplay = onboardingReplay.value
|
||||
const settings = onboardingSettings.value ?? (await getSettings())
|
||||
const settings = await getSettings()
|
||||
if (!onboardingReplay.value) {
|
||||
if (onboardingMode.value === 'instance') {
|
||||
settings.onboarding_instance_tour_completed = true
|
||||
@@ -2197,10 +2226,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnniversaryCelebration />
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||
<InstancePlayerModal v-if="stateInitialized" />
|
||||
<StartupAnnouncementModal ref="startupAnnouncementModal" />
|
||||
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||
@@ -2296,6 +2327,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="themeStore.featureFlags.multiplayer_in_app"
|
||||
v-tooltip.right="formatMessage(messages.multiplayer)"
|
||||
data-onboarding-id="nav-multiplayer"
|
||||
to="/multiplayer"
|
||||
:is-primary="(r) => r.path.startsWith('/multiplayer')"
|
||||
>
|
||||
<UsersIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.screenshots)"
|
||||
data-onboarding-id="nav-screenshots"
|
||||
to="/screenshots"
|
||||
:is-primary="(r) => r.path.startsWith('/screenshots')"
|
||||
>
|
||||
<ImagesIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.lab)"
|
||||
data-onboarding-id="nav-lab"
|
||||
@@ -2509,7 +2557,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
|
||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
data-onboarding-id="sidebar-accounts"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.userInformation) }}
|
||||
</h3>
|
||||
|
||||
@@ -32,6 +32,401 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
||||
]
|
||||
|
||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
id: 'launcher-1.0.2-beta7',
|
||||
version: '1.0.2-beta7',
|
||||
publishedAt: '2026-10-17',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta7',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta7',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed real-time log backlog that could stall the console under heavy output.',
|
||||
'zh-CN': '修复实时日志积压导致控制台卡顿的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
|
||||
'zh-CN': '更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
|
||||
},
|
||||
{
|
||||
'en-US': 'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
|
||||
'zh-CN': 'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta6',
|
||||
version: '1.0.2-beta6',
|
||||
publishedAt: '2026-10-11',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta6',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta6',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
|
||||
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
|
||||
'zh-CN': '新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
|
||||
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added a launcher log export entry.',
|
||||
'zh-CN': '新增启动器日志导出入口。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Instance groups can now be reordered by dragging.',
|
||||
'zh-CN': '实例分组支持拖拽重排。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US': 'Synced the latest supported AI chat model list from LobeHub.',
|
||||
'zh-CN': '同步 LobeHub 最新的 AI 聊天模型列表。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed remote announcements not refreshing.',
|
||||
'zh-CN': '修复远端公告不刷新的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Completed the Chinese translations for the armor preview feature.',
|
||||
'zh-CN': '补齐盔甲预览功能的中文翻译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta5',
|
||||
version: '1.0.2-beta5',
|
||||
publishedAt: '2026-10-11',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta5',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta5',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
|
||||
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
|
||||
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Completed the Chinese translations for the armor preview feature.',
|
||||
'zh-CN': '补齐盔甲预览功能的中文翻译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta4',
|
||||
version: '1.0.2-beta4',
|
||||
publishedAt: '2026-10-08',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta4',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
|
||||
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
|
||||
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
|
||||
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
|
||||
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed instance group changes not saving for some instances.',
|
||||
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta3',
|
||||
version: '1.0.2-beta3',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta3',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta3',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
|
||||
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta2',
|
||||
version: '1.0.2-beta2',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta2',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta2',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
|
||||
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta1',
|
||||
version: '1.0.2-beta1',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta1',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta1',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
|
||||
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
|
||||
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
|
||||
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1',
|
||||
version: '1.0.1',
|
||||
publishedAt: '2026-10-03',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
|
||||
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
|
||||
},
|
||||
{
|
||||
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
|
||||
'zh-CN': '在外观设置中自定义启动器界面字体与等宽字体。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
|
||||
'zh-CN': '修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
|
||||
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta10',
|
||||
version: '1.0.1-beta10',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta10',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta10',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
|
||||
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
|
||||
},
|
||||
{
|
||||
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
|
||||
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed the launcher UI scale reverting after a restart.',
|
||||
'zh-CN': '修复启动器界面缩放重启后恢复原状的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed developer feature flags not saving.',
|
||||
'zh-CN': '修复开发者功能开关无法保存的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.',
|
||||
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
|
||||
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed being unable to copy text inside the embedded skin site.',
|
||||
'zh-CN': '修复内嵌皮肤站中无法复制文本的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed the onboarding prompt not dismissing when clicking anywhere.',
|
||||
'zh-CN': '修复导览提示点击任意位置无法关闭的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta9',
|
||||
version: '1.0.1-beta9',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta9',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta9',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed a crash on the minimal home layout caused by an unrecognized multiplayer feature flag (missing backend enum variant).',
|
||||
'zh-CN': '修复多人联机功能开关缺少后端枚举变体、导致精简主页报错的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta8',
|
||||
version: '1.0.1-beta8',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta8',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta8',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the sidebar navigation entries breaking after the screenshot center was added.',
|
||||
'zh-CN': '修复添加截图中心后侧边栏导航项损坏的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed being unable to install the StarLight hosted pack even after signing in (missing command permission).',
|
||||
'zh-CN': '修复登录后仍无法安装 StarLight 实例的问题(命令权限缺失)。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the launcher UI scaling not applying when changed (missing command permission).',
|
||||
'zh-CN': '修复修改启动器界面缩放后不生效的问题(命令权限缺失)。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed several screenshot center actions failing due to missing command permissions.',
|
||||
'zh-CN': '修复截图中心部分操作因命令权限缺失而失败的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta7',
|
||||
version: '1.0.1-beta7',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta7',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta7',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Added a screenshot center: browse screenshots across every instance, group them (custom groups, by instance, or by date), preview, edit (crop, rotate, annotate, censor), and export as ZIP.',
|
||||
'zh-CN':
|
||||
'新增截图中心:跨实例浏览截图,支持自定义/按实例/按日期分组,可预览、编辑(裁剪、标注、打码等)并导出为 ZIP。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added the multiplayer (Terracotta) entry to the sidebar, behind a feature flag.',
|
||||
'zh-CN': '侧边栏新增多人联机(陶瓦)入口,由功能开关控制。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Launcher UI scaling is now persisted in the backend and applied via native webview zoom, so it scales embedded pages correctly and keeps onboarding anchors aligned.',
|
||||
'zh-CN':
|
||||
'启动器界面缩放改为后端持久化,并通过原生 webview 缩放应用,可正确缩放内嵌页面且导览锚点不再偏移。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour skipping its first step when the target anchor was missing.',
|
||||
'zh-CN': '修复导览首步在目标锚点缺失时被自动跳过的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta6',
|
||||
version: '1.0.1-beta6',
|
||||
publishedAt: '2026-10-01',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta6',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta6',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
|
||||
'zh-CN':
|
||||
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour target on the minimal home layout.',
|
||||
'zh-CN': '修复精简主页下导览目标找不到的问题。',
|
||||
},
|
||||
],
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Feature flag names in developer settings are now shown in Chinese.',
|
||||
'zh-CN': '开发者设置中的功能开关名现在显示中文。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta5',
|
||||
version: '1.0.1-beta5',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -201,7 +201,7 @@ onUnmounted(() => {
|
||||
data-onboarding-id="home-instances"
|
||||
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
|
||||
>
|
||||
<div class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||
<HomeGreeting :player-name="playerName" variant="minimal" />
|
||||
|
||||
<template v-if="selectedInstance">
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { anniversaryYear, ordinal, shouldCelebrate } from '@/helpers/anniversary'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const messages = defineMessages({
|
||||
banner: {
|
||||
id: 'app.anniversary.banner',
|
||||
defaultMessage: 'Happy {ordinal} Anniversary, Starlight!',
|
||||
},
|
||||
})
|
||||
|
||||
/** Emoji that drift down from the top of the window. */
|
||||
const CONFETTI_EMOJI = ['🎂', '🎉', '🎊', '🎈', '✨', '🧁', '🍰']
|
||||
|
||||
type Confetti = {
|
||||
id: number
|
||||
emoji: string
|
||||
left: number
|
||||
delay: number
|
||||
duration: number
|
||||
size: number
|
||||
drift: number
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const years = ref(0)
|
||||
const confetti = ref<Confetti[]>([])
|
||||
let spawnTimer: number | undefined
|
||||
let nextId = 0
|
||||
|
||||
function spawnConfetti(): void {
|
||||
const emoji = CONFETTI_EMOJI[Math.floor(Math.random() * CONFETTI_EMOJI.length)]
|
||||
confetti.value.push({
|
||||
id: nextId++,
|
||||
emoji,
|
||||
left: Math.random() * 100,
|
||||
delay: 0,
|
||||
duration: 6 + Math.random() * 5,
|
||||
size: 1 + Math.random() * 1.4,
|
||||
drift: (Math.random() - 0.5) * 160,
|
||||
})
|
||||
// Keep the DOM small; drop the oldest once we exceed a cap.
|
||||
if (confetti.value.length > 60) confetti.value.splice(0, 10)
|
||||
}
|
||||
|
||||
const forced = computed(() => themeStore.getFeatureFlag('force_anniversary_celebration'))
|
||||
|
||||
function startCelebration(): void {
|
||||
if (spawnTimer !== undefined) return
|
||||
years.value = anniversaryYear()
|
||||
visible.value = true
|
||||
// Seed a few so the screen is not empty on the first frame.
|
||||
for (let i = 0; i < 12; i++) spawnConfetti()
|
||||
spawnTimer = window.setInterval(spawnConfetti, 700)
|
||||
}
|
||||
|
||||
function stopCelebration(): void {
|
||||
if (spawnTimer !== undefined) {
|
||||
window.clearInterval(spawnTimer)
|
||||
spawnTimer = undefined
|
||||
}
|
||||
confetti.value = []
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => shouldCelebrate(forced.value),
|
||||
(active) => (active ? startCelebration() : stopCelebration()),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(stopCelebration)
|
||||
|
||||
const bannerText = computed(() =>
|
||||
formatMessage(messages.banner, { ordinal: ordinal(years.value) }),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="anniversary-fade">
|
||||
<div v-if="visible" class="anniversary-root" aria-live="polite">
|
||||
<!-- Falling confetti layer (click-through). -->
|
||||
<div class="anniversary-confetti" aria-hidden="true">
|
||||
<span
|
||||
v-for="piece in confetti"
|
||||
:key="piece.id"
|
||||
class="anniversary-piece"
|
||||
:style="{
|
||||
left: piece.left + '%',
|
||||
fontSize: piece.size + 'rem',
|
||||
animationDelay: piece.delay + 's',
|
||||
animationDuration: piece.duration + 's',
|
||||
'--drift': piece.drift + 'px',
|
||||
}"
|
||||
>
|
||||
{{ piece.emoji }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Top banner. -->
|
||||
<div class="anniversary-banner">
|
||||
<span class="anniversary-banner-emoji">🎂</span>
|
||||
<span class="anniversary-banner-text">{{ bannerText }}</span>
|
||||
<span class="anniversary-banner-emoji">🎉</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.anniversary-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above content, below modals/drag overlays (which use z-[200]). */
|
||||
z-index: 150;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.anniversary-confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anniversary-piece {
|
||||
position: absolute;
|
||||
top: -3rem;
|
||||
will-change: transform;
|
||||
animation-name: anniversary-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes anniversary-fall {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--drift, 0), 110vh, 0) rotate(360deg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.anniversary-banner {
|
||||
position: absolute;
|
||||
/* 避开原生窗口标题栏(Windows/macOS 系统装饰条会盖住 top:0 的内容);
|
||||
titlebar-area-height 是 Tauri 暴露的标题栏高度变量,不支持时降级为 0 */
|
||||
top: env(titlebar-area-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
/* 半透明深色兜底:即使下面的渐变背景失效,白字也不会在浅色页面上隐形 */
|
||||
background-color: rgba(20, 16, 28, 0.35);
|
||||
/* 半透明彩色渐变:让窗口下方的内容能透出来 */
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(244, 114, 182, 0.8),
|
||||
rgba(250, 204, 21, 0.8),
|
||||
rgba(74, 222, 128, 0.8),
|
||||
rgba(56, 189, 248, 0.8),
|
||||
rgba(167, 139, 250, 0.8),
|
||||
rgba(244, 114, 182, 0.8)
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
animation: anniversary-gradient 8s linear infinite;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||
0 0 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.anniversary-banner-text {
|
||||
animation: anniversary-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anniversary-banner-emoji {
|
||||
animation: anniversary-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes anniversary-gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 300% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anniversary-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anniversary-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.anniversary-fade-enter-active,
|
||||
.anniversary-fade-leave-active {
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.anniversary-fade-enter-from,
|
||||
.anniversary-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anniversary-piece,
|
||||
.anniversary-banner,
|
||||
.anniversary-banner-text,
|
||||
.anniversary-banner-emoji {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,6 +67,23 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ButtonStyled
|
||||
v-tooltip="formatMessage(messages.announcements)"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
:aria-label="formatMessage(messages.announcements)"
|
||||
class="relative"
|
||||
@click="openAnnouncementCenter"
|
||||
>
|
||||
<NewspaperIcon />
|
||||
<span
|
||||
v-if="announcementUnreadCount"
|
||||
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
@@ -190,6 +207,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BellIcon,
|
||||
NewspaperIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
@@ -211,7 +229,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import {
|
||||
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
|
||||
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
|
||||
} from '@/helpers/remote-announcements'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
|
||||
const downloadManager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const announcementUnreadCount = ref(0)
|
||||
|
||||
function openAnnouncementCenter() {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
|
||||
}
|
||||
|
||||
function updateAnnouncementCount(event: Event) {
|
||||
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
|
||||
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
|
||||
})
|
||||
|
||||
type NotificationHistoryItem = {
|
||||
key: string
|
||||
createdAt?: number
|
||||
@@ -370,6 +411,10 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
announcements: {
|
||||
id: 'app.action-bar.announcements',
|
||||
defaultMessage: 'Announcements',
|
||||
},
|
||||
clearNotifications: {
|
||||
id: 'app.action-bar.notifications.clear',
|
||||
defaultMessage: 'Clear all',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,425 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useModalStack,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
announcementKey,
|
||||
isAnnouncementActive,
|
||||
mapBroadcastAnnouncements,
|
||||
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
|
||||
parseAnnouncements,
|
||||
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
|
||||
type RemoteAnnouncement,
|
||||
safeAnnouncementUrl,
|
||||
} from '@/helpers/remote-announcements'
|
||||
|
||||
import {
|
||||
requestSkinSiteDownloadToken,
|
||||
skinSiteStatus,
|
||||
waitForSkinSiteSession,
|
||||
} from '@/composables/skin-site-session.ts'
|
||||
|
||||
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { hasModal } = useModalStack()
|
||||
const messages = defineMessages({
|
||||
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
|
||||
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
|
||||
readAll: {
|
||||
id: 'app.remote-announcements.read-all',
|
||||
defaultMessage: 'Mark all announcements as read',
|
||||
},
|
||||
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
|
||||
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
|
||||
previewTitle: {
|
||||
id: 'app.remote-announcements.preview-title',
|
||||
defaultMessage: 'Announcement style preview',
|
||||
},
|
||||
previewSummary: {
|
||||
id: 'app.remote-announcements.preview-summary',
|
||||
defaultMessage:
|
||||
'This is a local preview. Select View announcement to preview the full Markdown content.',
|
||||
},
|
||||
previewContent: {
|
||||
id: 'app.remote-announcements.preview-content',
|
||||
defaultMessage:
|
||||
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
|
||||
},
|
||||
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
|
||||
})
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const selected = ref<RemoteAnnouncement | null>(null)
|
||||
const active = ref(false)
|
||||
const centerOpen = ref(false)
|
||||
const startupNotice = ref<RemoteAnnouncement | null>(null)
|
||||
const html = computed(() => renderString(selected.value?.content ?? ''))
|
||||
const stateKey = 'starlight-remote-announcements-v2'
|
||||
const read = reactive(new Set<string>())
|
||||
const queuedThisSession = new Set<string>()
|
||||
const startupNotified = new Set<string>()
|
||||
let items: RemoteAnnouncement[] = []
|
||||
let pending: RemoteAnnouncement[] = []
|
||||
let cacheKey = 'starlight-remote-announcements-cache-v1'
|
||||
let cacheLoaded = false
|
||||
let disposed = false
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function persist() {
|
||||
if (props.previewOnly) return
|
||||
try {
|
||||
localStorage.setItem(
|
||||
stateKey,
|
||||
JSON.stringify({
|
||||
read: [...read].slice(-1000),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / quota)
|
||||
}
|
||||
}
|
||||
function emitState() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
|
||||
detail: {
|
||||
items,
|
||||
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
function openCenter(item?: RemoteAnnouncement) {
|
||||
if (!props.ready || disposed) return
|
||||
void refresh()
|
||||
centerOpen.value = true
|
||||
if (item) {
|
||||
startupNotice.value = null
|
||||
selectAnnouncement(item)
|
||||
} else if (!selected.value && items.length) {
|
||||
selectAnnouncement(items[0])
|
||||
}
|
||||
void nextTick(() => modal.value?.show())
|
||||
}
|
||||
function handleOpenCenter() {
|
||||
if (props.previewOnly) return
|
||||
openCenter()
|
||||
}
|
||||
async function show(item: RemoteAnnouncement) {
|
||||
if (
|
||||
disposed ||
|
||||
!props.ready ||
|
||||
!isAnnouncementActive(item) ||
|
||||
(hasModal.value && !active.value && !centerOpen.value)
|
||||
)
|
||||
return
|
||||
centerOpen.value = true
|
||||
active.value = true
|
||||
selectAnnouncement(item)
|
||||
await nextTick()
|
||||
if (!disposed) modal.value?.show()
|
||||
}
|
||||
function selectAnnouncement(item: RemoteAnnouncement) {
|
||||
selected.value = item
|
||||
const key = announcementKey(item)
|
||||
read.add(key)
|
||||
pending = pending.filter((entry) => announcementKey(entry) !== key)
|
||||
persist()
|
||||
emitState()
|
||||
}
|
||||
function markAllRead() {
|
||||
for (const item of items) {
|
||||
read.add(announcementKey(item))
|
||||
}
|
||||
pending = []
|
||||
persist()
|
||||
emitState()
|
||||
}
|
||||
function advance() {
|
||||
if (disposed || !props.ready || hasModal.value || active.value) return
|
||||
const next = pending.shift()
|
||||
if (next) {
|
||||
void show(next)
|
||||
return
|
||||
}
|
||||
for (const item of items) {
|
||||
const key = announcementKey(item)
|
||||
if (
|
||||
item.type === 'notification' &&
|
||||
!startupNotified.has(key) &&
|
||||
!read.has(key) &&
|
||||
isAnnouncementActive(item)
|
||||
) {
|
||||
startupNotice.value = item
|
||||
startupNotified.add(key)
|
||||
setTimeout(() => {
|
||||
if (startupNotice.value === item) {
|
||||
startupNotice.value = null
|
||||
advance()
|
||||
}
|
||||
}, 8000)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
function closed() {
|
||||
active.value = false
|
||||
centerOpen.value = false
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
advanceTimer = setTimeout(advance, 350)
|
||||
}
|
||||
function sync(next: RemoteAnnouncement[], fresh: boolean) {
|
||||
items = next.filter((item) => isAnnouncementActive(item))
|
||||
const keys = new Set(items.map(announcementKey))
|
||||
pending = pending.filter((item) => keys.has(announcementKey(item)))
|
||||
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
|
||||
emitState()
|
||||
if (fresh) {
|
||||
for (const item of items) {
|
||||
const key = announcementKey(item)
|
||||
if (
|
||||
item.type === 'modal' &&
|
||||
!queuedThisSession.has(key) &&
|
||||
(!read.has(key) || item.priority === 'critical')
|
||||
) {
|
||||
queuedThisSession.add(key)
|
||||
pending.push(item)
|
||||
}
|
||||
}
|
||||
advance()
|
||||
}
|
||||
}
|
||||
function loadCache() {
|
||||
if (cacheLoaded || !cacheKey) return
|
||||
cacheLoaded = true
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
|
||||
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
|
||||
const parsed = parseAnnouncements(cached.items)
|
||||
if (parsed) sync(parsed, false)
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or expired cache payloads
|
||||
}
|
||||
}
|
||||
function saveCache(next: RemoteAnnouncement[]) {
|
||||
if (!cacheKey) return
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / quota)
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
if (disposed) return
|
||||
loadCache()
|
||||
if (skinSiteStatus.value !== 'signed-in') return
|
||||
try {
|
||||
await waitForSkinSiteSession()
|
||||
const token = await requestSkinSiteDownloadToken()
|
||||
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
|
||||
if (parsed && !disposed) {
|
||||
sync(parsed, true)
|
||||
saveCache(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Remote fetch failed; previously cached items remain displayed
|
||||
}
|
||||
}
|
||||
async function openLink(value: unknown) {
|
||||
const url = safeAnnouncementUrl(value)
|
||||
if (!url) return
|
||||
try {
|
||||
await openUrl(url)
|
||||
} catch {
|
||||
// Opener may reject unknown schemes
|
||||
}
|
||||
}
|
||||
function contentClick(event: MouseEvent) {
|
||||
const link = event.target instanceof Element ? event.target.closest('a') : null
|
||||
if (!link) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void openLink(link.getAttribute('href'))
|
||||
}
|
||||
function preview(type: RemoteAnnouncement['type'], withAction = false) {
|
||||
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
|
||||
const now = new Date().toISOString()
|
||||
const item: RemoteAnnouncement = {
|
||||
id: 'local-preview-' + type,
|
||||
title: formatMessage(messages.previewTitle),
|
||||
summary: formatMessage(messages.previewSummary),
|
||||
content: formatMessage(messages.previewContent),
|
||||
type,
|
||||
priority: 'normal',
|
||||
starts_at: now,
|
||||
ends_at: null,
|
||||
published_at: now,
|
||||
action_label: withAction ? formatMessage(messages.previewAction) : null,
|
||||
action_url: withAction ? 'https://skin.starlight.cool/' : null,
|
||||
}
|
||||
read.clear()
|
||||
queuedThisSession.clear()
|
||||
sync([item], true)
|
||||
}
|
||||
defineExpose({ preview })
|
||||
onMounted(() => {
|
||||
if (props.previewOnly) return
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
|
||||
if (saved && Array.isArray(saved.read))
|
||||
for (const key of saved.read) if (typeof key === 'string') read.add(key)
|
||||
} catch {
|
||||
// Ignore malformed local read-state
|
||||
}
|
||||
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
||||
const start = () => {
|
||||
if (disposed) return
|
||||
void refresh()
|
||||
interval = setInterval(() => {
|
||||
sync(items, false)
|
||||
advance()
|
||||
}, 15000)
|
||||
}
|
||||
// Defer the startup request until after first paint.
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(start, { timeout: 2000 })
|
||||
} else {
|
||||
setTimeout(start, 500)
|
||||
}
|
||||
})
|
||||
watch(skinSiteStatus, (status) => {
|
||||
if (disposed || props.previewOnly) return
|
||||
if (status === 'signed-in') void refresh()
|
||||
})
|
||||
watch([() => props.ready, hasModal], () => {
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
advanceTimer = setTimeout(advance, 350)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
if (interval) clearInterval(interval)
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="startupNotice"
|
||||
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
|
||||
>
|
||||
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
|
||||
<div class="mb-1 text-xs font-semibold uppercase text-brand">
|
||||
{{ formatMessage(messages.unread) }}
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
|
||||
<div
|
||||
v-if="startupNotice.summary"
|
||||
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ startupNotice.summary }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.centerTitle)"
|
||||
:on-hide="closed"
|
||||
max-width="80vw"
|
||||
width="80vw"
|
||||
max-content-height="calc(80vh - 8rem)"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
|
||||
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.centerTitle)
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="items.some((item) => !read.has(announcementKey(item)))"
|
||||
@click="markAllRead"
|
||||
>{{ formatMessage(messages.readAll) }}</Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!items.length"
|
||||
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="announcementKey(item)"
|
||||
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
|
||||
:class="{
|
||||
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
|
||||
}"
|
||||
@click="selectAnnouncement(item)"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-2 shrink-0 rounded-full"
|
||||
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
|
||||
item.summary || item.content
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
|
||||
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
{{ selected.title }}
|
||||
</h2>
|
||||
<div
|
||||
class="markdown-body break-words"
|
||||
@click="contentClick"
|
||||
@auxclick="contentClick"
|
||||
v-html="html"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button
|
||||
v-if="selected.action_url && selected.action_label"
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="openLink(selected.action_url)"
|
||||
>{{ selected.action_label }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ const messages = defineMessages({
|
||||
<template>
|
||||
<iframe
|
||||
:src="skinSiteFrameUrl"
|
||||
allow="clipboard-write; clipboard-read"
|
||||
:title="formatMessage(messages.frameTitle)"
|
||||
class="block h-full min-h-0 w-full border-0"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, FolderIcon, MinusIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
type BackupExclusion,
|
||||
type BackupExclusionKind,
|
||||
canonicalizeBackupExclusions,
|
||||
formatBackupExclusionPath,
|
||||
normalizeBackupExclusion,
|
||||
} from '@/helpers/instance-backup'
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId: string
|
||||
instanceRoot: string
|
||||
modelValue: BackupExclusion[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: BackupExclusion[]] }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const picking = ref<BackupExclusionKind | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
empty: {
|
||||
id: 'instance.backups.exclusions.empty',
|
||||
defaultMessage: 'No files or folders are excluded.',
|
||||
},
|
||||
addFile: {
|
||||
id: 'instance.backups.exclusions.add-file',
|
||||
defaultMessage: 'Add excluded file',
|
||||
},
|
||||
addFolder: {
|
||||
id: 'instance.backups.exclusions.add-folder',
|
||||
defaultMessage: 'Add excluded folder',
|
||||
},
|
||||
remove: {
|
||||
id: 'instance.backups.exclusions.remove',
|
||||
defaultMessage: 'Remove {path} from exclusions',
|
||||
},
|
||||
})
|
||||
|
||||
function pickedPath(result: unknown): string | null {
|
||||
if (typeof result === 'string') return result
|
||||
if (result && typeof result === 'object' && 'path' in result) {
|
||||
const path = (result as { path?: unknown }).path
|
||||
return typeof path === 'string' ? path : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function addExclusion(kind: BackupExclusionKind) {
|
||||
if (props.disabled || picking.value) return
|
||||
picking.value = kind
|
||||
try {
|
||||
const result = await open({
|
||||
directory: kind === 'directory',
|
||||
multiple: false,
|
||||
defaultPath: props.instanceRoot,
|
||||
})
|
||||
const selectedPath = pickedPath(result)
|
||||
if (!selectedPath) return
|
||||
const exclusion = await normalizeBackupExclusion(props.instanceId, selectedPath, kind)
|
||||
emit('update:modelValue', canonicalizeBackupExclusions([...props.modelValue, exclusion]))
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
picking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function removeExclusion(path: string) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((exclusion) => exclusion.path !== path),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p v-if="modelValue.length === 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
|
||||
>
|
||||
<div
|
||||
v-for="exclusion in modelValue"
|
||||
:key="exclusion.path"
|
||||
class="flex min-h-11 items-center gap-3 px-3 py-2"
|
||||
>
|
||||
<FolderIcon
|
||||
v-if="exclusion.kind === 'directory'"
|
||||
class="size-5 shrink-0 text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<FileIcon v-else class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<code class="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{{
|
||||
formatBackupExclusionPath(exclusion)
|
||||
}}</code>
|
||||
<Button
|
||||
type="quiet"
|
||||
size="2xs"
|
||||
circular
|
||||
icon-only
|
||||
:disabled="disabled || picking !== null"
|
||||
:aria-label="
|
||||
formatMessage(messages.remove, { path: formatBackupExclusionPath(exclusion) })
|
||||
"
|
||||
@click="removeExclusion(exclusion.path)"
|
||||
><MinusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="outlined" :disabled="disabled || picking !== null" @click="addExclusion('file')"
|
||||
><PlusIcon />
|
||||
{{ formatMessage(messages.addFile) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="outlined"
|
||||
:disabled="disabled || picking !== null"
|
||||
@click="addExclusion('directory')"
|
||||
><PlusIcon />
|
||||
{{ formatMessage(messages.addFolder) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,675 @@
|
||||
<script setup lang="ts">
|
||||
import { FileArchiveIcon, SaveIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import {
|
||||
type BackupExclusion,
|
||||
type BackupOperation,
|
||||
type BackupOperationState,
|
||||
type BackupProgressStage,
|
||||
type BackupRestorePreview,
|
||||
type BackupSnapshot,
|
||||
cancelBackup,
|
||||
deleteBackup,
|
||||
disableBackups,
|
||||
enableBackups,
|
||||
getBackupConfig,
|
||||
type InstanceBackupConfig,
|
||||
type InstanceBackupEligibility,
|
||||
listBackupOperations,
|
||||
listBackups,
|
||||
listenBackupProgress,
|
||||
restoreBackup,
|
||||
startBackup,
|
||||
startBackupRestorePreview,
|
||||
updateBackupExclusions,
|
||||
} from '@/helpers/instance-backup'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import BackupExclusionSelector from './BackupExclusionSelector.vue'
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.backups.title', defaultMessage: 'Instance backups' },
|
||||
description: {
|
||||
id: 'instance.backups.description',
|
||||
defaultMessage: 'Create deduplicated snapshots of the whole instance while it is closed.',
|
||||
},
|
||||
loading: { id: 'instance.backups.loading', defaultMessage: 'Loading backup settings...' },
|
||||
unavailable: { id: 'instance.backups.unavailable', defaultMessage: 'Backups are unavailable' },
|
||||
notInstalled: {
|
||||
id: 'instance.backups.unavailable.not-installed',
|
||||
defaultMessage: 'Finish installing this instance before enabling backups.',
|
||||
},
|
||||
symlinkInstance: {
|
||||
id: 'instance.backups.unavailable.symlink',
|
||||
defaultMessage: 'Imported symbolic-link instances cannot be backed up.',
|
||||
},
|
||||
directLinked: {
|
||||
id: 'instance.backups.unavailable.direct-linked',
|
||||
defaultMessage: 'Instances linked directly to another launcher cannot be backed up.',
|
||||
},
|
||||
externalDirectory: {
|
||||
id: 'instance.backups.unavailable.external-directory',
|
||||
defaultMessage: 'Instances that use an external game directory cannot be backed up.',
|
||||
},
|
||||
rootMissing: {
|
||||
id: 'instance.backups.unavailable.root-missing',
|
||||
defaultMessage: 'The instance folder is missing.',
|
||||
},
|
||||
rootLink: {
|
||||
id: 'instance.backups.unavailable.root-link',
|
||||
defaultMessage: 'Instances whose root folder is a link cannot be backed up.',
|
||||
},
|
||||
choose: {
|
||||
id: 'instance.backups.choose',
|
||||
defaultMessage:
|
||||
'Everything is backed up by default. Add files or folders that should be excluded.',
|
||||
},
|
||||
enable: { id: 'instance.backups.enable', defaultMessage: 'Enable backups' },
|
||||
editExclusions: { id: 'instance.backups.edit-exclusions', defaultMessage: 'Edit exclusions' },
|
||||
saveExclusions: {
|
||||
id: 'instance.backups.save-exclusions',
|
||||
defaultMessage: 'Save exclusions',
|
||||
},
|
||||
allIncluded: {
|
||||
id: 'instance.backups.all-included',
|
||||
defaultMessage: 'All instance files and folders are included.',
|
||||
},
|
||||
excludedPaths: {
|
||||
id: 'instance.backups.excluded-paths',
|
||||
defaultMessage: 'Excluded: {paths}',
|
||||
},
|
||||
backupNow: { id: 'instance.backups.backup-now', defaultMessage: 'Back up now' },
|
||||
cancelBackup: { id: 'instance.backups.cancel', defaultMessage: 'Cancel backup' },
|
||||
stageScanning: {
|
||||
id: 'instance.backups.stage.scanning',
|
||||
defaultMessage: 'Scanning files',
|
||||
},
|
||||
stageHashing: { id: 'instance.backups.stage.hashing', defaultMessage: 'Hashing files' },
|
||||
stageSaving: { id: 'instance.backups.stage.saving', defaultMessage: 'Saving snapshot index' },
|
||||
stageQueued: { id: 'instance.backups.stage.queued', defaultMessage: 'Waiting to start' },
|
||||
stageValidating: {
|
||||
id: 'instance.backups.stage.validating',
|
||||
defaultMessage: 'Validating restore',
|
||||
},
|
||||
stageMaterializing: {
|
||||
id: 'instance.backups.stage.materializing',
|
||||
defaultMessage: 'Preparing changed files',
|
||||
},
|
||||
stageApplying: {
|
||||
id: 'instance.backups.stage.applying',
|
||||
defaultMessage: 'Applying changed files',
|
||||
},
|
||||
stagePreview: {
|
||||
id: 'instance.backups.stage.preview',
|
||||
defaultMessage: 'Analyzing restore changes',
|
||||
},
|
||||
progressBytes: {
|
||||
id: 'instance.backups.progress-bytes',
|
||||
defaultMessage: '{processed} of {total}',
|
||||
},
|
||||
stageFailed: { id: 'instance.backups.stage.failed', defaultMessage: 'Backup failed' },
|
||||
stageInterrupted: {
|
||||
id: 'instance.backups.stage.interrupted',
|
||||
defaultMessage: 'Backup operation was interrupted',
|
||||
},
|
||||
snapshots: { id: 'instance.backups.snapshots', defaultMessage: 'Snapshots' },
|
||||
empty: { id: 'instance.backups.empty', defaultMessage: 'No backups have been created yet.' },
|
||||
snapshotMeta: {
|
||||
id: 'instance.backups.snapshot-meta',
|
||||
defaultMessage: '{files, plural, one {# file} other {# files}} · {size}',
|
||||
},
|
||||
addedSize: { id: 'instance.backups.added-size', defaultMessage: '{size} newly stored' },
|
||||
restore: { id: 'instance.backups.restore', defaultMessage: 'Restore backup' },
|
||||
delete: { id: 'instance.backups.delete', defaultMessage: 'Delete backup' },
|
||||
disable: { id: 'instance.backups.disable', defaultMessage: 'Disable backups' },
|
||||
disableBlocked: {
|
||||
id: 'instance.backups.disable-blocked',
|
||||
defaultMessage: 'Delete every snapshot before disabling backups.',
|
||||
},
|
||||
restoreTitle: { id: 'instance.backups.restore-title', defaultMessage: 'Restore this backup?' },
|
||||
restoreBody: {
|
||||
id: 'instance.backups.restore-body',
|
||||
defaultMessage:
|
||||
'Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?',
|
||||
},
|
||||
deleteTitle: { id: 'instance.backups.delete-title', defaultMessage: 'Delete this backup?' },
|
||||
deleteBody: {
|
||||
id: 'instance.backups.delete-body',
|
||||
defaultMessage:
|
||||
'This snapshot will be permanently deleted. Unused stored files will also be removed.',
|
||||
},
|
||||
})
|
||||
|
||||
const config = ref<InstanceBackupConfig | null>(null)
|
||||
const snapshots = ref<BackupSnapshot[]>([])
|
||||
const instanceRoot = ref('')
|
||||
const loading = ref(true)
|
||||
const editingExclusions = ref(false)
|
||||
const excludedPaths = ref<BackupExclusion[]>([])
|
||||
const action = ref<string | null>(null)
|
||||
const operation = ref<BackupOperation | null>(null)
|
||||
const selectedSnapshot = ref<BackupSnapshot | null>(null)
|
||||
const restorePreview = ref<BackupRestorePreview | null>(null)
|
||||
const deleteModal = ref<InstanceType<typeof NewModal>>()
|
||||
const restoreModal = ref<InstanceType<typeof NewModal>>()
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
const eligible = computed(() => config.value?.eligibility === 'eligible')
|
||||
const terminalStates: BackupOperationState[] = ['completed', 'cancelled', 'failed', 'interrupted']
|
||||
const isRunning = computed(
|
||||
() => operation.value !== null && !terminalStates.includes(operation.value.state),
|
||||
)
|
||||
const canCancel = computed(() => operation.value?.cancellable === true)
|
||||
const operationLabel = computed(() => {
|
||||
if (operation.value?.operation_type === 'restore_preview') {
|
||||
return formatMessage(messages.stagePreview)
|
||||
}
|
||||
switch (operation.value?.state) {
|
||||
case 'queued':
|
||||
return formatMessage(messages.stageQueued)
|
||||
case 'hashing':
|
||||
return formatMessage(messages.stageHashing)
|
||||
case 'saving':
|
||||
return formatMessage(messages.stageSaving)
|
||||
case 'validating':
|
||||
return formatMessage(messages.stageValidating)
|
||||
case 'materializing':
|
||||
return formatMessage(messages.stageMaterializing)
|
||||
case 'applying':
|
||||
return formatMessage(messages.stageApplying)
|
||||
default:
|
||||
return formatMessage(messages.stageScanning)
|
||||
}
|
||||
})
|
||||
|
||||
function operationFromEvent(event: {
|
||||
operationId: string
|
||||
operationType: BackupOperation['operation_type']
|
||||
instanceId?: string
|
||||
stage: BackupProgressStage
|
||||
processedBytes: number
|
||||
totalBytes: number
|
||||
snapshotId?: string
|
||||
message?: string
|
||||
}): BackupOperation {
|
||||
const now = Date.now()
|
||||
const state: BackupOperationState =
|
||||
event.stage === 'restoring' || event.stage === 'copying'
|
||||
? 'materializing'
|
||||
: event.stage === 'deleting'
|
||||
? 'queued'
|
||||
: event.stage
|
||||
return {
|
||||
id: event.operationId,
|
||||
operation_type: event.operationType,
|
||||
instance_id: event.instanceId ?? instance.value.id,
|
||||
snapshot_id: event.snapshotId,
|
||||
state,
|
||||
processed_bytes: event.processedBytes,
|
||||
total_bytes: event.totalBytes,
|
||||
cancellable: event.stage === 'scanning' || event.stage === 'hashing',
|
||||
cancel_requested: false,
|
||||
error: event.message,
|
||||
created_at: operation.value?.id === event.operationId ? operation.value.created_at : now,
|
||||
updated_at: now,
|
||||
finished_at: ['completed', 'cancelled', 'failed'].includes(event.stage) ? now : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOperation() {
|
||||
const operations = await listBackupOperations(instance.value.id)
|
||||
const latest = operations[0] ?? null
|
||||
operation.value =
|
||||
latest && (isRunningState(latest.state) || ['failed', 'interrupted'].includes(latest.state))
|
||||
? latest
|
||||
: null
|
||||
if (operation.value?.operation_type === 'restore_preview' && operation.value.snapshot_id) {
|
||||
selectedSnapshot.value =
|
||||
snapshots.value.find((snapshot) => snapshot.id === operation.value?.snapshot_id) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function isRunningState(state: BackupOperationState) {
|
||||
return !terminalStates.includes(state)
|
||||
}
|
||||
|
||||
const eligibilityMessages: Record<
|
||||
Exclude<InstanceBackupEligibility, 'eligible'>,
|
||||
keyof typeof messages
|
||||
> = {
|
||||
not_installed: 'notInstalled',
|
||||
symlink_instance: 'symlinkInstance',
|
||||
direct_linked_instance: 'directLinked',
|
||||
external_game_directory: 'externalDirectory',
|
||||
root_missing: 'rootMissing',
|
||||
root_is_link: 'rootLink',
|
||||
}
|
||||
|
||||
const unavailableReason = computed(() => {
|
||||
const eligibility = config.value?.eligibility
|
||||
if (!eligibility || eligibility === 'eligible') return ''
|
||||
return formatMessage(messages[eligibilityMessages[eligibility]])
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const nextConfig = await getBackupConfig(instance.value.id)
|
||||
config.value = nextConfig
|
||||
excludedPaths.value = nextConfig.enabled ? [...nextConfig.excluded_paths] : []
|
||||
const [nextRoot, nextSnapshots] = await Promise.all([
|
||||
nextConfig.eligibility === 'eligible' ? get_full_path(instance.value.id) : '',
|
||||
listBackups(instance.value.id),
|
||||
])
|
||||
instanceRoot.value = nextRoot
|
||||
snapshots.value = nextSnapshots
|
||||
await refreshOperation()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(name: string, task: () => Promise<void>) {
|
||||
action.value = name
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
action.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function enable() {
|
||||
void runAction('enable', async () => {
|
||||
config.value = await enableBackups(instance.value.id, excludedPaths.value)
|
||||
editingExclusions.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function beginEditingExclusions() {
|
||||
excludedPaths.value = [...(config.value?.excluded_paths ?? [])]
|
||||
editingExclusions.value = true
|
||||
}
|
||||
|
||||
function saveExclusions() {
|
||||
void runAction('exclusions', async () => {
|
||||
config.value = await updateBackupExclusions(instance.value.id, excludedPaths.value)
|
||||
editingExclusions.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function start() {
|
||||
void runAction('start', async () => {
|
||||
const id = await startBackup(instance.value.id)
|
||||
operation.value = {
|
||||
id,
|
||||
operation_type: 'create',
|
||||
instance_id: instance.value.id,
|
||||
state: 'queued',
|
||||
processed_bytes: 0,
|
||||
total_bytes: 0,
|
||||
cancellable: true,
|
||||
cancel_requested: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!operation.value || !canCancel.value) return
|
||||
void runAction('cancel', async () => {
|
||||
if (await cancelBackup(operation.value!.id)) {
|
||||
operation.value = {
|
||||
...operation.value!,
|
||||
cancellable: false,
|
||||
cancel_requested: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDelete(snapshot: BackupSnapshot) {
|
||||
selectedSnapshot.value = snapshot
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
function confirmRestore(snapshot: BackupSnapshot) {
|
||||
void runAction(`preview:${snapshot.id}`, async () => {
|
||||
selectedSnapshot.value = snapshot
|
||||
const id = await startBackupRestorePreview(snapshot.id)
|
||||
operation.value = {
|
||||
id,
|
||||
operation_type: 'restore_preview',
|
||||
instance_id: instance.value.id,
|
||||
snapshot_id: snapshot.id,
|
||||
state: 'queued',
|
||||
processed_bytes: 0,
|
||||
total_bytes: snapshot.logical_size,
|
||||
cancellable: true,
|
||||
cancel_requested: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeSnapshot() {
|
||||
if (!selectedSnapshot.value) return
|
||||
void runAction(`delete:${selectedSnapshot.value.id}`, async () => {
|
||||
await deleteBackup(selectedSnapshot.value!.id)
|
||||
await refresh()
|
||||
deleteModal.value?.hide()
|
||||
selectedSnapshot.value = null
|
||||
})
|
||||
}
|
||||
|
||||
function restoreSnapshot() {
|
||||
if (!selectedSnapshot.value || !restorePreview.value) return
|
||||
void runAction(`restore:${selectedSnapshot.value.id}`, async () => {
|
||||
const id = await restoreBackup(selectedSnapshot.value!.id, restorePreview.value!.plan_token)
|
||||
operation.value = {
|
||||
id,
|
||||
operation_type: 'restore',
|
||||
instance_id: instance.value.id,
|
||||
snapshot_id: selectedSnapshot.value!.id,
|
||||
state: 'queued',
|
||||
processed_bytes: 0,
|
||||
total_bytes: selectedSnapshot.value!.logical_size,
|
||||
cancellable: true,
|
||||
cancel_requested: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
restoreModal.value?.hide()
|
||||
selectedSnapshot.value = null
|
||||
restorePreview.value = null
|
||||
})
|
||||
}
|
||||
|
||||
function disable() {
|
||||
void runAction('disable', async () => {
|
||||
await disableBackups(instance.value.id)
|
||||
await refresh()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
unlisten = await listenBackupProgress(async (event) => {
|
||||
if (
|
||||
!['create', 'restore', 'restore_preview'].includes(event.operationType) ||
|
||||
event.instanceId !== instance.value.id
|
||||
)
|
||||
return
|
||||
operation.value = operationFromEvent(event)
|
||||
if (event.operationType === 'restore_preview' && event.stage === 'completed') {
|
||||
const completed = (await listBackupOperations(instance.value.id)).find(
|
||||
(candidate) => candidate.id === event.operationId,
|
||||
)
|
||||
const snapshot = snapshots.value.find((candidate) => candidate.id === event.snapshotId)
|
||||
if (completed?.restore_preview && snapshot) {
|
||||
selectedSnapshot.value = snapshot
|
||||
restorePreview.value = completed.restore_preview
|
||||
operation.value = null
|
||||
restoreModal.value?.show()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (['completed', 'cancelled', 'failed'].includes(event.stage)) {
|
||||
void refresh()
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => unlisten?.())
|
||||
watch(
|
||||
() => instance.value.id,
|
||||
() => void refresh(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<header>
|
||||
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="m-0 text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</p>
|
||||
<Admonition v-else-if="!eligible" type="warning" :header="formatMessage(messages.unavailable)">
|
||||
{{ unavailableReason }}
|
||||
</Admonition>
|
||||
<template v-else-if="config">
|
||||
<div v-if="!config.enabled || editingExclusions" class="flex flex-col gap-4">
|
||||
<p class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.choose) }}</p>
|
||||
<BackupExclusionSelector
|
||||
v-model="excludedPaths"
|
||||
:instance-id="instance.id"
|
||||
:instance-root="instanceRoot"
|
||||
:disabled="action !== null"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button :disabled="action !== null" @click="config.enabled ? saveExclusions() : enable()"
|
||||
><SaveIcon />
|
||||
{{ formatMessage(config.enabled ? messages.saveExclusions : messages.enable) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="config.enabled"
|
||||
type="outlined"
|
||||
:disabled="action !== null"
|
||||
@click="editingExclusions = false"
|
||||
><XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-4 p-3">
|
||||
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{
|
||||
config.excluded_paths.length === 0
|
||||
? formatMessage(messages.allIncluded)
|
||||
: formatMessage(messages.excludedPaths, {
|
||||
paths: config.excluded_paths.map((entry) => entry.path).join(', '),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button :disabled="isRunning || action !== null" @click="start"
|
||||
><FileArchiveIcon />
|
||||
{{ formatMessage(messages.backupNow) }}
|
||||
</Button>
|
||||
<Button v-if="canCancel" type="outlined" :disabled="action !== null" @click="cancel"
|
||||
><XIcon />
|
||||
{{ formatMessage(messages.cancelBackup) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="outlined"
|
||||
:disabled="isRunning || action !== null"
|
||||
@click="beginEditingExclusions"
|
||||
>{{ formatMessage(messages.editExclusions) }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="isRunning" class="m-0 text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{{ operationLabel }}
|
||||
<span
|
||||
v-if="(operation?.total_bytes ?? 0) > 0"
|
||||
class="ml-2 text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.progressBytes, {
|
||||
processed: formatBytes(operation?.processed_bytes ?? 0),
|
||||
total: formatBytes(operation?.total_bytes ?? 0),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="operation?.state === 'failed' || operation?.state === 'interrupted'"
|
||||
class="m-0 text-sm text-red"
|
||||
>
|
||||
{{
|
||||
formatMessage(
|
||||
operation.state === 'interrupted'
|
||||
? messages.stageInterrupted
|
||||
: messages.stageFailed,
|
||||
)
|
||||
}}<span v-if="operation.error">: {{ operation.error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="m-0 text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.snapshots) }}
|
||||
</h3>
|
||||
<p v-if="snapshots.length === 0" class="m-0 text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
|
||||
>
|
||||
<div
|
||||
v-for="snapshot in snapshots"
|
||||
:key="snapshot.id"
|
||||
class="flex items-center gap-3 px-3 py-3"
|
||||
>
|
||||
<FileArchiveIcon class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatDate(new Date(snapshot.created_at)) }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{
|
||||
formatMessage(messages.snapshotMeta, {
|
||||
files: snapshot.file_count,
|
||||
size: formatBytes(snapshot.logical_size),
|
||||
})
|
||||
}}
|
||||
·
|
||||
{{
|
||||
formatMessage(messages.addedSize, { size: formatBytes(snapshot.added_size) })
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="quiet"
|
||||
size="2xs"
|
||||
circular
|
||||
icon-only
|
||||
:disabled="action !== null || isRunning"
|
||||
:aria-label="formatMessage(messages.restore)"
|
||||
@click="confirmRestore(snapshot)"
|
||||
><UndoIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="quiet"
|
||||
color="red"
|
||||
size="2xs"
|
||||
circular
|
||||
icon-only
|
||||
:disabled="action !== null || isRunning"
|
||||
:aria-label="formatMessage(messages.delete)"
|
||||
@click="confirmDelete(snapshot)"
|
||||
><TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
type="outlined"
|
||||
color="red"
|
||||
:disabled="snapshots.length > 0 || action !== null || isRunning"
|
||||
@click="disable"
|
||||
>{{ formatMessage(messages.disable) }}
|
||||
</Button>
|
||||
<p v-if="snapshots.length > 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.disableBlocked) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NewModal
|
||||
ref="deleteModal"
|
||||
:header="formatMessage(messages.deleteTitle)"
|
||||
fade="danger"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition type="critical">{{ formatMessage(messages.deleteBody) }}</Admonition>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="outlined"
|
||||
:disabled="action?.startsWith('delete:')"
|
||||
@click="deleteModal?.hide()"
|
||||
>{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="action?.startsWith('delete:')"
|
||||
@click="removeSnapshot"
|
||||
><TrashIcon />{{ formatMessage(messages.delete) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
<NewModal ref="restoreModal" :header="formatMessage(messages.restoreTitle)" max-width="500px">
|
||||
<Admonition v-if="restorePreview" type="warning">
|
||||
{{
|
||||
formatMessage(messages.restoreBody, {
|
||||
added: restorePreview.added_files,
|
||||
modified: restorePreview.modified_files,
|
||||
deleted: restorePreview.deleted_files,
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="outlined"
|
||||
:disabled="action?.startsWith('restore:')"
|
||||
@click="restoreModal?.hide()"
|
||||
>{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button :disabled="action?.startsWith('restore:')" @click="restoreSnapshot"
|
||||
><UndoIcon />{{ formatMessage(messages.restore) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,618 @@
|
||||
<script setup lang="ts">
|
||||
import { EditIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { gameSettingsKeys } from '@/helpers/game-options'
|
||||
import {
|
||||
type GlobalSyncedOptions,
|
||||
isSyncedOptionAvailable,
|
||||
set_global_synced_option,
|
||||
type SyncedOption,
|
||||
} from '@/helpers/instance'
|
||||
import {
|
||||
canSourceMultiplayerServers,
|
||||
gameOptionsSyncSourcesQueryOptions,
|
||||
globalSyncedOptionsQueryOptions,
|
||||
initializedSyncedOptionsQueryOptions,
|
||||
syncedOptionsKeys,
|
||||
syncedServersQueryOptions,
|
||||
} from '@/helpers/synced-options'
|
||||
import { syncedPackKeys, syncedPackQueryOptions } from '@/helpers/synced-packs'
|
||||
import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options'
|
||||
|
||||
import SettingsRow from '../settings/SettingsRow.vue'
|
||||
import SettingsSection from '../settings/SettingsSection.vue'
|
||||
import CommandHistoryModal from '../settings/instances/command-history-modal.vue'
|
||||
import SyncedServersModal from '../settings/instances/servers-modal.vue'
|
||||
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
|
||||
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
|
||||
import GameSettingsModal from '../settings/instances/game-settings-modal/index.vue'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
|
||||
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
|
||||
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
|
||||
const gameSettingsModal = ref<InstanceType<typeof GameSettingsModal> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
globalOptionsTitle: {
|
||||
id: 'app.settings.synced-options.title',
|
||||
defaultMessage: 'Options synced across all instances',
|
||||
},
|
||||
resourcePacks: {
|
||||
id: 'app.settings.synced-options.resource-packs',
|
||||
defaultMessage: 'Sync resource packs',
|
||||
},
|
||||
resourcePacksDescription: {
|
||||
id: 'app.settings.synced-options.resource-packs.description',
|
||||
defaultMessage: 'Use the same resource packs across your instances.',
|
||||
},
|
||||
dataPacks: { id: 'app.settings.synced-options.data-packs', defaultMessage: 'Sync data packs' },
|
||||
dataPacksDescription: {
|
||||
id: 'app.settings.synced-options.data-packs.description',
|
||||
defaultMessage: 'Use the same datapacks across your instances',
|
||||
},
|
||||
packsDisabled: {
|
||||
id: 'app.settings.synced-options.packs.disabled',
|
||||
defaultMessage: 'Enable syncing before editing shared packs.',
|
||||
},
|
||||
gameSettings: {
|
||||
id: 'app.settings.synced-options.game-settings',
|
||||
defaultMessage: 'Sync game options',
|
||||
},
|
||||
gameSettingsDescription: {
|
||||
id: 'app.settings.synced-options.game-settings.description',
|
||||
defaultMessage: 'Use the same game options across your instances.',
|
||||
},
|
||||
gameSettingsButton: {
|
||||
id: 'app.settings.synced-options.game-settings.button',
|
||||
defaultMessage: 'Edit game settings',
|
||||
},
|
||||
gameSettingsDisabledTooltip: {
|
||||
id: 'app.settings.synced-options.game-settings.disabled-tooltip',
|
||||
defaultMessage: 'Enable game settings sync before choosing individual settings.',
|
||||
},
|
||||
noGameOptionsToEdit: {
|
||||
id: 'app.settings.synced-options.game-settings.no-options-to-edit',
|
||||
defaultMessage: "You haven't got any options yet to edit",
|
||||
},
|
||||
multiplayerServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers',
|
||||
defaultMessage: 'Sync multiplayer servers',
|
||||
},
|
||||
multiplayerServersDescription: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.description',
|
||||
defaultMessage: 'Use the same multiplayer servers across your instances.',
|
||||
},
|
||||
commandHistory: {
|
||||
id: 'app.settings.synced-options.command-history',
|
||||
defaultMessage: 'Sync command history',
|
||||
},
|
||||
commandHistoryDescription: {
|
||||
id: 'app.settings.synced-options.command-history.description',
|
||||
defaultMessage: 'Use the same command history across your instances.',
|
||||
},
|
||||
creativeHotbars: {
|
||||
id: 'app.settings.synced-options.creative-hotbars',
|
||||
defaultMessage: 'Sync saved creative hotbars',
|
||||
},
|
||||
creativeHotbarsDescription: {
|
||||
id: 'app.settings.synced-options.creative-hotbars.description',
|
||||
defaultMessage: 'Use the same saved creative hotbars across your instances.',
|
||||
},
|
||||
gameSettingsSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.game-settings-title',
|
||||
defaultMessage: 'Choose game settings source',
|
||||
},
|
||||
multiplayerServersSyncSourceDescription: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||
},
|
||||
multiplayerServersSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-title',
|
||||
defaultMessage: 'Choose multiplayer servers source',
|
||||
},
|
||||
commandHistorySyncSourceDescription: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward.',
|
||||
},
|
||||
commandHistorySyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.command-history-title',
|
||||
defaultMessage: 'Choose command history source',
|
||||
},
|
||||
creativeHotbarsSyncSourceDescription: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||
},
|
||||
creativeHotbarsSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-title',
|
||||
defaultMessage: 'Choose creative hotbars source',
|
||||
},
|
||||
gameSettingsSyncSourceDescription: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.game-settings-description',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||
},
|
||||
resourcePacksSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.resource-packs-title',
|
||||
defaultMessage: 'Choose resource packs source',
|
||||
},
|
||||
resourcePacksSyncSourceDescription: {
|
||||
id: 'app.sync-instances-update.choose-source.resource-packs',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||
},
|
||||
dataPacksSyncSourceTitle: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.data-packs-title',
|
||||
defaultMessage: 'Choose data packs source',
|
||||
},
|
||||
dataPacksSyncSourceDescription: {
|
||||
id: 'app.sync-instances-update.choose-source.data-packs',
|
||||
defaultMessage:
|
||||
'Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||
},
|
||||
noServersSyncedYet: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
|
||||
defaultMessage: "You haven't synced any servers yet",
|
||||
},
|
||||
noResourcePacksSyncedYet: {
|
||||
id: 'app.settings.synced-options.resource-packs.none-synced-yet',
|
||||
defaultMessage: "You haven't synced any resource packs yet",
|
||||
},
|
||||
noDataPacksSyncedYet: {
|
||||
id: 'app.settings.synced-options.data-packs.none-synced-yet',
|
||||
defaultMessage: "You haven't synced any data packs yet",
|
||||
},
|
||||
noInstancesToEdit: {
|
||||
id: 'app.settings.synced-options.edit.no-instances',
|
||||
defaultMessage: 'Add an instance before editing synced data.',
|
||||
},
|
||||
noSyncedDataToEdit: {
|
||||
id: 'app.settings.synced-options.edit.no-synced-data',
|
||||
defaultMessage: 'Choose a sync source before editing this setting.',
|
||||
},
|
||||
})
|
||||
|
||||
const globalRows: Array<{
|
||||
option: SyncedOption
|
||||
title: keyof typeof messages
|
||||
description?: keyof typeof messages
|
||||
editable?: 'game-settings' | 'servers' | 'commands' | 'resourcepack' | 'datapack'
|
||||
}> = [
|
||||
{
|
||||
option: 'game_options',
|
||||
title: 'gameSettings',
|
||||
description: 'gameSettingsDescription',
|
||||
editable: 'game-settings',
|
||||
},
|
||||
{
|
||||
option: 'multiplayer_servers',
|
||||
title: 'multiplayerServers',
|
||||
description: 'multiplayerServersDescription',
|
||||
editable: 'servers',
|
||||
},
|
||||
{
|
||||
option: 'resource_packs',
|
||||
title: 'resourcePacks',
|
||||
description: 'resourcePacksDescription',
|
||||
editable: 'resourcepack',
|
||||
},
|
||||
{
|
||||
option: 'data_packs',
|
||||
title: 'dataPacks',
|
||||
description: 'dataPacksDescription',
|
||||
editable: 'datapack',
|
||||
},
|
||||
{
|
||||
option: 'command_history',
|
||||
title: 'commandHistory',
|
||||
description: 'commandHistoryDescription',
|
||||
editable: 'commands',
|
||||
},
|
||||
{
|
||||
option: 'creative_hotbars',
|
||||
title: 'creativeHotbars',
|
||||
description: 'creativeHotbarsDescription',
|
||||
},
|
||||
]
|
||||
|
||||
const availableGlobalRows = globalRows.filter((row) => isSyncedOptionAvailable(row.option))
|
||||
|
||||
const defaultGlobalOptions: GlobalSyncedOptions = {
|
||||
resource_packs: false,
|
||||
data_packs: false,
|
||||
game_options: false,
|
||||
command_history: false,
|
||||
multiplayer_servers: false,
|
||||
creative_hotbars: false,
|
||||
screenshots: false,
|
||||
}
|
||||
|
||||
const globalOptionsQuery = useQuery(globalSyncedOptionsQueryOptions())
|
||||
const initializedOptionsQuery = useQuery(initializedSyncedOptionsQueryOptions())
|
||||
const gameOptionSourcesQuery = useQuery(gameOptionsSyncSourcesQueryOptions())
|
||||
const resourcePacksQuery = useQuery(syncedPackQueryOptions('resourcepack'))
|
||||
const dataPacksQuery = useQuery(syncedPackQueryOptions('datapack'))
|
||||
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
|
||||
const initializedOptions = computed(
|
||||
() => initializedOptionsQuery.data.value ?? defaultGlobalOptions,
|
||||
)
|
||||
const gameOptionSources = computed(() => gameOptionSourcesQuery.data.value ?? [])
|
||||
const instancesQuery = useQuery(instanceListQueryOptions())
|
||||
const instances = computed(() => instancesQuery.data.value ?? [])
|
||||
const serversQuery = useQuery(syncedServersQueryOptions())
|
||||
const syncedServers = computed(() => serversQuery.data.value ?? [])
|
||||
const eligibleGameOptionSourceIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
gameOptionSources.value.filter((source) => source.eligible).map((source) => source.source_id),
|
||||
),
|
||||
)
|
||||
const hasGameOptionsToEdit = computed(
|
||||
() =>
|
||||
initializedOptions.value.game_options ||
|
||||
instances.value.some(
|
||||
(instance) =>
|
||||
instance?.synced_options?.game_options &&
|
||||
eligibleGameOptionSourceIds.value.has(instance.id),
|
||||
),
|
||||
)
|
||||
const baseOption = ref<SyncedOption | null>(null)
|
||||
const baseInstanceId = ref('')
|
||||
const baseModal = ref<InstanceType<typeof SyncSourceModal> | null>(null)
|
||||
const baseSourcesLoading = computed(() =>
|
||||
baseOption.value === null
|
||||
? false
|
||||
: baseOption.value === 'game_options'
|
||||
? gameOptionSourcesQuery.isPending.value
|
||||
: instancesQuery.isPending.value,
|
||||
)
|
||||
const baseSourcesError = computed(() =>
|
||||
baseOption.value === 'game_options'
|
||||
? gameOptionSourcesQuery.isError.value && !gameOptionSourcesQuery.data.value
|
||||
: instancesQuery.isError.value && !instancesQuery.data.value,
|
||||
)
|
||||
let baseSourceGeneration = 0
|
||||
|
||||
const baseInstanceTitle = computed(() => {
|
||||
switch (baseOption.value) {
|
||||
case 'game_options':
|
||||
return formatMessage(messages.gameSettingsSyncSourceTitle)
|
||||
case 'multiplayer_servers':
|
||||
return formatMessage(messages.multiplayerServersSyncSourceTitle)
|
||||
case 'command_history':
|
||||
return formatMessage(messages.commandHistorySyncSourceTitle)
|
||||
case 'creative_hotbars':
|
||||
return formatMessage(messages.creativeHotbarsSyncSourceTitle)
|
||||
case 'resource_packs':
|
||||
return formatMessage(messages.resourcePacksSyncSourceTitle)
|
||||
case 'data_packs':
|
||||
return formatMessage(messages.dataPacksSyncSourceTitle)
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const baseInstanceDescription = computed(() => {
|
||||
switch (baseOption.value) {
|
||||
case 'game_options':
|
||||
return formatMessage(messages.gameSettingsSyncSourceDescription)
|
||||
case 'multiplayer_servers':
|
||||
return formatMessage(messages.multiplayerServersSyncSourceDescription)
|
||||
case 'command_history':
|
||||
return formatMessage(messages.commandHistorySyncSourceDescription)
|
||||
case 'creative_hotbars':
|
||||
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
|
||||
case 'resource_packs':
|
||||
return formatMessage(messages.resourcePacksSyncSourceDescription)
|
||||
case 'data_packs':
|
||||
return formatMessage(messages.dataPacksSyncSourceDescription)
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const baseInstances = computed(() =>
|
||||
baseOption.value === 'game_options'
|
||||
? gameOptionSources.value.map((source) => ({
|
||||
id: source.source_id,
|
||||
name: source.name,
|
||||
icon_path: source.icon_path,
|
||||
eligible: source.eligible,
|
||||
}))
|
||||
: instances.value.map((instance) => ({
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
icon_path: instance.icon_path,
|
||||
eligible:
|
||||
!instance.quarantined &&
|
||||
(baseOption.value !== 'multiplayer_servers' || canSourceMultiplayerServers(instance)),
|
||||
})),
|
||||
)
|
||||
|
||||
async function invalidateSyncedOptions() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worlds'] }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||
])
|
||||
}
|
||||
|
||||
function emptySyncedContentMessage(row: (typeof globalRows)[number]) {
|
||||
if (
|
||||
row.editable === 'servers' &&
|
||||
(instances.value.length === 0 || syncedServers.value.length === 0)
|
||||
) {
|
||||
return messages.noServersSyncedYet
|
||||
}
|
||||
if (row.editable === 'resourcepack' && !resourcePacksQuery.data.value?.length) {
|
||||
return messages.noResourcePacksSyncedYet
|
||||
}
|
||||
if (row.editable === 'datapack' && !dataPacksQuery.data.value?.length) {
|
||||
return messages.noDataPacksSyncedYet
|
||||
}
|
||||
}
|
||||
|
||||
function canEditGlobalOption(row: (typeof globalRows)[number]): boolean {
|
||||
if (!row.editable || !globalOptions.value[row.option]) return false
|
||||
if (emptySyncedContentMessage(row)) return false
|
||||
if (row.editable === 'resourcepack' || row.editable === 'datapack') return true
|
||||
if (row.editable === 'game-settings') return hasGameOptionsToEdit.value
|
||||
return instances.value.length > 0 && initializedOptions.value[row.option]
|
||||
}
|
||||
|
||||
function editGlobalOptionTooltip(row: (typeof globalRows)[number]): string {
|
||||
const emptyMessage = emptySyncedContentMessage(row)
|
||||
if (emptyMessage) return formatMessage(emptyMessage)
|
||||
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
|
||||
return formatMessage(
|
||||
globalOptions.value[row.option] ? commonMessages.editButton : messages.packsDisabled,
|
||||
)
|
||||
}
|
||||
if (row.editable === 'game-settings') {
|
||||
if (!hasGameOptionsToEdit.value) return formatMessage(messages.noGameOptionsToEdit)
|
||||
if (!globalOptions.value[row.option]) {
|
||||
return formatMessage(messages.gameSettingsDisabledTooltip)
|
||||
}
|
||||
return formatMessage(messages.gameSettingsButton)
|
||||
}
|
||||
if (instances.value.length === 0) {
|
||||
return formatMessage(messages.noInstancesToEdit)
|
||||
}
|
||||
if (!initializedOptions.value[row.option]) {
|
||||
return formatMessage(messages.noSyncedDataToEdit)
|
||||
}
|
||||
return formatMessage(commonMessages.editButton)
|
||||
}
|
||||
|
||||
type GlobalOptionMutationVariables = {
|
||||
option: SyncedOption
|
||||
enabled: boolean
|
||||
baseInstanceId?: string
|
||||
}
|
||||
|
||||
const globalOptionMutation = useMutation({
|
||||
mutationKey: syncedOptionsKeys.set,
|
||||
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
|
||||
set_global_synced_option(option, enabled, baseInstanceId),
|
||||
onError: handleError,
|
||||
onSuccess: async (options, { option, enabled }) => {
|
||||
queryClient.setQueryData(syncedOptionsKeys.global, options)
|
||||
if (option === 'game_options') {
|
||||
await queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
|
||||
}
|
||||
if (enabled && option === 'multiplayer_servers') {
|
||||
await queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.servers })
|
||||
}
|
||||
},
|
||||
onSettled: async (_data, _error, { option }) => {
|
||||
if (queryClient.isMutating({ mutationKey: syncedOptionsKeys.set }) === 1) {
|
||||
if (option === 'resource_packs' || option === 'data_packs') {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
|
||||
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||
])
|
||||
return
|
||||
}
|
||||
await invalidateSyncedOptions()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const canToggleGlobalOptions = computed(
|
||||
() =>
|
||||
!!globalOptionsQuery.data.value &&
|
||||
!globalOptionMutation.isPending.value &&
|
||||
!baseSourcesLoading.value,
|
||||
)
|
||||
|
||||
async function chooseBaseInstance(option: SyncedOption) {
|
||||
const generation = ++baseSourceGeneration
|
||||
baseOption.value = option
|
||||
baseInstanceId.value = ''
|
||||
|
||||
try {
|
||||
if (option === 'game_options') {
|
||||
await queryClient.fetchQuery(gameOptionsSyncSourcesQueryOptions())
|
||||
} else {
|
||||
await queryClient.fetchQuery({ ...instanceListQueryOptions(), staleTime: 0 })
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== baseSourceGeneration) return
|
||||
handleError(error)
|
||||
baseModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (generation !== baseSourceGeneration) return
|
||||
const eligibleSources = baseInstances.value.filter((source) => source.eligible)
|
||||
baseInstanceId.value = eligibleSources[0]?.id ?? ''
|
||||
if (eligibleSources.length > 1) {
|
||||
baseModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await globalOptionMutation.mutateAsync({
|
||||
option,
|
||||
enabled: true,
|
||||
baseInstanceId: eligibleSources[0]?.id,
|
||||
})
|
||||
await nextTick()
|
||||
if (!baseModal.value?.hide()) clearBaseSource()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||
if (!isSyncedOptionAvailable(option) || !canToggleGlobalOptions.value) return
|
||||
if (enabled && option !== 'screenshots') {
|
||||
void chooseBaseInstance(option)
|
||||
return
|
||||
}
|
||||
globalOptionMutation.mutate({ option, enabled })
|
||||
}
|
||||
|
||||
async function confirmBaseInstance() {
|
||||
if (!baseOption.value || !baseInstanceId.value) return
|
||||
try {
|
||||
await globalOptionMutation.mutateAsync({
|
||||
option: baseOption.value,
|
||||
enabled: true,
|
||||
baseInstanceId: baseInstanceId.value,
|
||||
})
|
||||
await nextTick()
|
||||
baseModal.value?.hide()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function editGlobalOption(row: (typeof globalRows)[number]) {
|
||||
if (!isSyncedOptionAvailable(row.option)) return
|
||||
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
|
||||
void syncedPacksModal.value?.show(row.editable)
|
||||
} else if (row.editable === 'game-settings') {
|
||||
openGameSettings()
|
||||
} else if (row.editable === 'commands') {
|
||||
void commandHistoryModal.value?.show()
|
||||
} else {
|
||||
void syncedServersModal.value?.show()
|
||||
}
|
||||
}
|
||||
|
||||
function openGameSettings() {
|
||||
if (!hasGameOptionsToEdit.value) return
|
||||
gameSettingsModal.value?.show()
|
||||
}
|
||||
|
||||
async function handleGameSettingsSaved() {
|
||||
await invalidateSyncedOptions()
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearBaseSource() {
|
||||
baseSourceGeneration++
|
||||
baseOption.value = null
|
||||
}
|
||||
|
||||
onScopeDispose(clearBaseSource)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SyncedPacksModal ref="syncedPacksModal" />
|
||||
<CommandHistoryModal ref="commandHistoryModal" />
|
||||
<SyncedServersModal ref="syncedServersModal" />
|
||||
<GameSettingsModal ref="gameSettingsModal" @saved="handleGameSettingsSaved" />
|
||||
|
||||
<SyncSourceModal
|
||||
ref="baseModal"
|
||||
v-model="baseInstanceId"
|
||||
:title="baseInstanceTitle"
|
||||
:description="baseInstanceDescription"
|
||||
:sources="baseInstances"
|
||||
:loading="baseSourcesLoading"
|
||||
:error="baseSourcesError"
|
||||
:pending="globalOptionMutation.isPending.value"
|
||||
@confirm="confirmBaseInstance"
|
||||
@close="clearBaseSource"
|
||||
@retry="baseOption && chooseBaseInstance(baseOption)"
|
||||
/>
|
||||
|
||||
<SettingsSection>
|
||||
<template #header>
|
||||
<h2
|
||||
id="settings-target-instance-sync-options"
|
||||
tabindex="-1"
|
||||
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ formatMessage(messages.globalOptionsTitle) }}
|
||||
</h2>
|
||||
</template>
|
||||
<SettingsRow v-for="row in availableGlobalRows" :key="row.option">
|
||||
<template #label>
|
||||
<span :id="`settings-target-instance-sync-${row.option}`" tabindex="-1">
|
||||
{{ formatMessage(messages[row.title]) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="row.description" #description>
|
||||
{{ formatMessage(messages[row.description]) }}
|
||||
</template>
|
||||
<template #control>
|
||||
<span v-if="row.editable" v-tooltip="editGlobalOptionTooltip(row)" class="flex">
|
||||
<IconButton
|
||||
type="outlined"
|
||||
circular
|
||||
:disabled="
|
||||
!canEditGlobalOption(row) ||
|
||||
initializedOptionsQuery.isPending.value ||
|
||||
globalOptionMutation.isPending.value
|
||||
"
|
||||
:label="
|
||||
formatMessage(
|
||||
row.editable === 'game-settings'
|
||||
? messages.gameSettingsButton
|
||||
: commonMessages.editButton,
|
||||
)
|
||||
"
|
||||
@click="editGlobalOption(row)"
|
||||
>
|
||||
<EditIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</span>
|
||||
<Toggle
|
||||
:id="`global-sync-${row.option}`"
|
||||
:model-value="globalOptions[row.option]"
|
||||
:disabled="!canToggleGlobalOptions"
|
||||
:aria-label="formatMessage(messages[row.title])"
|
||||
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,24 +36,29 @@ const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
|
||||
const windowTitle = ref(instance.value.window_title ?? '')
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const title = windowTitle.value.trim() ? windowTitle.value.trim() : null
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
maximize_window: null,
|
||||
game_resolution: null,
|
||||
// The title is independent of the window-size override checkbox.
|
||||
window_title: title,
|
||||
}
|
||||
}
|
||||
return {
|
||||
force_fullscreen: fullscreenSetting.value,
|
||||
maximize_window: maximizeWindowSetting.value,
|
||||
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||
window_title: title,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting, windowTitle],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
@@ -109,6 +114,18 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.window.height.enter',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
windowTitle: {
|
||||
id: 'instance.settings.tabs.window.window-title',
|
||||
defaultMessage: 'Window title',
|
||||
},
|
||||
windowTitleDescription: {
|
||||
id: 'instance.settings.tabs.window.window-title.description',
|
||||
defaultMessage: 'Customize the Minecraft window title. Leave empty to keep the default.',
|
||||
},
|
||||
enterWindowTitle: {
|
||||
id: 'instance.settings.tabs.window.window-title.enter',
|
||||
defaultMessage: 'Enter window title...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -200,5 +217,25 @@ const messages = defineMessages({
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalSettings.custom_window_title_enabled"
|
||||
class="flex items-center gap-4 justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 inline-flex items-center gap-2 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.windowTitle) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.windowTitleDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="window-title"
|
||||
v-model="windowTitle"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.enterWindowTitle)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
instanceGroupId: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'contextmenu' | 'card-click', event: MouseEvent): void
|
||||
(e: 'start-long-press' | 'cancel-long-press'): void
|
||||
}>()
|
||||
|
||||
const cardElement = ref<HTMLElement>()
|
||||
|
||||
const instanceCardSensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
const dragDisabled = computed(() => props.disabled)
|
||||
|
||||
const { isDragging } = useDraggable({
|
||||
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
|
||||
element: cardElement,
|
||||
disabled: dragDisabled,
|
||||
sensors: instanceCardSensors,
|
||||
data: computed(() => ({
|
||||
instanceId: props.instance.id,
|
||||
fromGroup: props.instanceGroupId,
|
||||
})),
|
||||
})
|
||||
|
||||
let wantsClick = false
|
||||
|
||||
useEventListener(window, 'pointerdown', () => {
|
||||
wantsClick = true
|
||||
})
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
if (isDragging.value) {
|
||||
wantsClick = false
|
||||
}
|
||||
})
|
||||
useEventListener(window, 'blur', () => {
|
||||
wantsClick = false
|
||||
})
|
||||
|
||||
function onCardClick(event: MouseEvent) {
|
||||
if (!wantsClick || isDragging.value) return
|
||||
wantsClick = false
|
||||
emit('card-click', event)
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent) {
|
||||
emit('contextmenu', event)
|
||||
}
|
||||
|
||||
function onMouseDown() {
|
||||
if (!dragDisabled.value) {
|
||||
emit('start-long-press')
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
emit('cancel-long-press')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="cardElement"
|
||||
class="group relative min-w-0 w-full"
|
||||
@click="onCardClick"
|
||||
@mousedown="onMouseDown"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
@touchstart="!dragDisabled && onMouseDown()"
|
||||
@touchend="onMouseUp"
|
||||
@touchcancel="onMouseUp"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
<slot :is-dragging="isDragging" />
|
||||
<slot v-if="!isDragging" name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
InlineEditableText,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import DraggableInstanceCard from '@/components/ui/library/DraggableInstanceCard.vue'
|
||||
import { UNGROUPED_GROUP_KEY } from '@/composables/useGridGrouping'
|
||||
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH = 256
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
|
||||
pinned: { id: 'app.instances.group.pinned', defaultMessage: 'Pinned' },
|
||||
emptyGroup: {
|
||||
id: 'app.instances.group.empty',
|
||||
defaultMessage: 'Drag and drop to add instances.',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.instances.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.instances.group.delete-group',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
deleteGroupDescription: {
|
||||
id: 'app.instances.group.delete-description',
|
||||
defaultMessage: 'Instances in this group will be ungrouped.',
|
||||
},
|
||||
moveGroupUp: {
|
||||
id: 'app.instances.group.move-up',
|
||||
defaultMessage: 'Move group up',
|
||||
},
|
||||
moveGroupDown: {
|
||||
id: 'app.instances.group.move-down',
|
||||
defaultMessage: 'Move group down',
|
||||
},
|
||||
addToGroup: {
|
||||
id: 'app.instances.group.add-to-group',
|
||||
defaultMessage: 'Add to group',
|
||||
},
|
||||
newInstance: {
|
||||
id: 'app.instances.group.new-instance',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
collapseGroup: {
|
||||
id: 'app.instances.group.collapse',
|
||||
defaultMessage: 'Collapse group',
|
||||
},
|
||||
expandGroup: {
|
||||
id: 'app.instances.group.expand',
|
||||
defaultMessage: 'Expand group',
|
||||
},
|
||||
renameGroup: {
|
||||
id: 'app.instances.group.rename-group',
|
||||
defaultMessage: 'Rename group',
|
||||
},
|
||||
groupNameEmpty: {
|
||||
id: 'app.instances.group.name-empty',
|
||||
defaultMessage: 'Group names cannot be empty.',
|
||||
},
|
||||
groupNameTooLong: {
|
||||
id: 'app.instances.group.name-too-long',
|
||||
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
sectionKey: string
|
||||
groupName?: string
|
||||
instances: GameInstance[]
|
||||
grouping: string
|
||||
isCollapsed: boolean
|
||||
displayMode: string
|
||||
selectMode: boolean
|
||||
selectedInstanceIds: Set<string>
|
||||
isSortAscending?: boolean
|
||||
hideHeader?: boolean
|
||||
canMoveUp?: boolean
|
||||
canMoveDown?: boolean
|
||||
groupPendingNameEdit?: string | null
|
||||
}>()
|
||||
|
||||
// Events are grouped only where the payload shapes genuinely match; the
|
||||
// per-event names are documentation, not part of the type.
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleCollapse' | 'deleteGroup' | 'addToGroup' | 'startLongPress', id: string): void
|
||||
(e: 'handleCheckboxClick' | 'handleCardClick', instanceId: string, event: MouseEvent): void
|
||||
(e: 'handleContextMenu', event: MouseEvent, instanceId: string, sectionKey: string): void
|
||||
(e: 'renameGroup', oldKey: string, newKey: string): void
|
||||
(e: 'cancelLongPress' | 'newInstance' | 'renameComplete'): void
|
||||
(e: 'moveGroup', groupKey: string, direction: -1 | 1): void
|
||||
}>()
|
||||
|
||||
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
|
||||
const groupContainerRef = ref<HTMLElement>()
|
||||
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const deletingGroup = ref(false)
|
||||
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupName = ref(props.groupName ?? props.sectionKey)
|
||||
|
||||
const isUngrouped = computed(() => props.sectionKey === UNGROUPED_GROUP_KEY)
|
||||
const isFavorites = computed(() => props.sectionKey === FAVORITES_GROUP_ID)
|
||||
const isCustomGroup = computed(
|
||||
() => props.grouping === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const isGroupDragActive = computed(() => props.grouping === 'Group')
|
||||
const effectiveCollapsed = computed(() => (props.hideHeader ? false : props.isCollapsed))
|
||||
|
||||
const { isDropTarget } = useDroppable({
|
||||
id: computed(() => `instance-group:${props.sectionKey}`),
|
||||
element: groupContainerRef,
|
||||
disabled: computed(() => props.hideHeader || !isGroupDragActive.value),
|
||||
data: computed(() => ({
|
||||
groupId: props.sectionKey,
|
||||
})),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.sectionKey, props.groupName],
|
||||
() => {
|
||||
groupName.value = props.groupName ?? props.sectionKey
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.groupPendingNameEdit,
|
||||
async (pendingId) => {
|
||||
if (pendingId !== props.sectionKey) return
|
||||
|
||||
await nextTick()
|
||||
groupContainerRef.value?.scrollIntoView({ block: 'nearest' })
|
||||
await groupNameInput.value?.startEditing()
|
||||
emit('renameComplete')
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
function validateGroupName(value: string) {
|
||||
const normalized = value.trim()
|
||||
if (normalized.length === 0) return false
|
||||
if (normalized.length > MAX_GROUP_NAME_LENGTH) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateGroupName(value: string) {
|
||||
const newName = value.trim()
|
||||
if (!newName || newName === groupName.value) return false
|
||||
emit('renameGroup', props.sectionKey, newName)
|
||||
return true
|
||||
}
|
||||
|
||||
function openGroupContextMenu(event: MouseEvent) {
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.sectionKey,
|
||||
isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
async function handleGroupOption({ option }: { option: string }) {
|
||||
if (option === 'new_instance') {
|
||||
emit('newInstance')
|
||||
} else if (option === 'add_instances') {
|
||||
emit('addToGroup', props.sectionKey)
|
||||
} else if (option === 'edit_name') {
|
||||
groupNameInput.value?.startEditing()
|
||||
} else if (option === 'delete_group') {
|
||||
requestGroupDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
function requestGroupDeletion() {
|
||||
if (isUngrouped.value || isFavorites.value) return
|
||||
|
||||
if (props.instances.length > 0) {
|
||||
confirmDeleteGroupModal.value?.show()
|
||||
} else {
|
||||
void removeGroup()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup() {
|
||||
if (deletingGroup.value) return
|
||||
|
||||
deletingGroup.value = true
|
||||
emit('deleteGroup', props.sectionKey)
|
||||
setTimeout(() => {
|
||||
deletingGroup.value = false
|
||||
confirmDeleteGroupModal.value?.hide()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onCardClick(instanceId: string, event: MouseEvent) {
|
||||
emit('handleCardClick', instanceId, event)
|
||||
}
|
||||
|
||||
function onStartLongPress(instanceId: string) {
|
||||
emit('startLongPress', instanceId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="groupContainerRef"
|
||||
class="instance-group group/instance-container relative select-none pb-3"
|
||||
@contextmenu.prevent.stop="openGroupContextMenu"
|
||||
>
|
||||
<div
|
||||
v-if="!hideHeader && isDropTarget && isGroupDragActive"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast opacity-40 bg-transparent transition-opacity duration-150"
|
||||
/>
|
||||
<div
|
||||
v-if="!hideHeader && (grouping === 'Group' || sectionKey !== UNGROUPED_GROUP_KEY)"
|
||||
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
:class="{ 'group-drag-handle cursor-grab': isCustomGroup }"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2"
|
||||
@click="emit('toggleCollapse', sectionKey)"
|
||||
>
|
||||
<button
|
||||
class="flex shrink-0 items-center border-0 bg-transparent p-0 cursor-pointer"
|
||||
type="button"
|
||||
:aria-expanded="!effectiveCollapsed"
|
||||
:aria-label="
|
||||
formatMessage(effectiveCollapsed ? messages.expandGroup : messages.collapseGroup)
|
||||
"
|
||||
@click.stop="emit('toggleCollapse', sectionKey)"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-300"
|
||||
:class="{ 'rotate-180': !effectiveCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
ref="groupNameInput"
|
||||
v-model="groupName"
|
||||
activation-mode="manual"
|
||||
class="text-base font-semibold !h-10 text-[var(--color-text-default)] select-none"
|
||||
max-width="24rem"
|
||||
:max-length="MAX_GROUP_NAME_LENGTH"
|
||||
:on-change="updateGroupName"
|
||||
:validate="validateGroupName"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-[var(--color-text-default)] select-none">
|
||||
{{ isFavorites ? formatMessage(messages.pinned) : formatMessage(messages.ungrouped) }}
|
||||
</span>
|
||||
<TagItem v-if="instances.length" class="shrink-0 border-surface-3 bg-surface-2">
|
||||
{{ instances.length }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<div
|
||||
v-if="isCustomGroup"
|
||||
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moveGroupUp)"
|
||||
:label="formatMessage(messages.moveGroupUp)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="!canMoveUp"
|
||||
@click.stop="emit('moveGroup', sectionKey, -1)"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moveGroupDown)"
|
||||
:label="formatMessage(messages.moveGroupDown)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="!canMoveDown"
|
||||
@click.stop="emit('moveGroup', sectionKey, 1)"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.editGroupName)"
|
||||
:label="formatMessage(messages.editGroupName)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="groupNameInput?.startEditing()"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.addToGroup)"
|
||||
:label="formatMessage(messages.addToGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="emit('addToGroup', sectionKey)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="deletingGroup"
|
||||
@click.stop="handleGroupOption({ option: 'delete_group' })"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!effectiveCollapsed" class="mt-2.5">
|
||||
<section
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full gap-3 mr-auto scroll-smooth overflow-y-auto"
|
||||
:class="{
|
||||
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
|
||||
}"
|
||||
>
|
||||
<template v-for="instance in instances" :key="instance.id">
|
||||
<DraggableInstanceCard
|
||||
v-if="isGroupDragActive"
|
||||
:instance="instance"
|
||||
:instance-group-id="sectionKey"
|
||||
:disabled="selectMode"
|
||||
@card-click="(event: MouseEvent) => onCardClick(instance.id, event)"
|
||||
@contextmenu="
|
||||
(event: MouseEvent) => emit('handleContextMenu', event, instance.id, props.sectionKey)
|
||||
"
|
||||
@start-long-press="onStartLongPress(instance.id)"
|
||||
@cancel-long-press="emit('cancelLongPress')"
|
||||
>
|
||||
<template #default="{ isDragging }">
|
||||
<div
|
||||
:class="{
|
||||
'pointer-events-none': selectMode,
|
||||
'relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]': true,
|
||||
}"
|
||||
@click.capture="
|
||||
(event: MouseEvent) => {
|
||||
if (event.shiftKey && !selectMode) {
|
||||
event.stopPropagation()
|
||||
emit('handleCardClick', instance.id, event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<Instance
|
||||
:instance="instance"
|
||||
:disabled="selectMode || isDragging"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
:class="{
|
||||
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 z-10 transition-opacity"
|
||||
:class="
|
||||
selectMode && selectedInstanceIds.has(instance.id)
|
||||
? ''
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
"
|
||||
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
|
||||
>
|
||||
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectMode"
|
||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="
|
||||
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
|
||||
"
|
||||
>
|
||||
<Button type="quiet" size="2xs" circular icon-only
|
||||
><slot name="moreIcon" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DraggableInstanceCard>
|
||||
|
||||
<div v-else class="group relative">
|
||||
<div
|
||||
class="relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]"
|
||||
@click.capture="
|
||||
(event: MouseEvent) => {
|
||||
if (event.shiftKey && !selectMode) {
|
||||
event.stopPropagation()
|
||||
emit('handleCardClick', instance.id, event)
|
||||
}
|
||||
}
|
||||
"
|
||||
@click="emit('handleCardClick', instance.id, $event as MouseEvent)"
|
||||
@mousedown="!selectMode && emit('startLongPress', instance.id)"
|
||||
@mouseup="emit('cancelLongPress')"
|
||||
@mouseleave="emit('cancelLongPress')"
|
||||
@touchstart="!selectMode && emit('startLongPress', instance.id)"
|
||||
@touchend="emit('cancelLongPress')"
|
||||
@touchcancel="emit('cancelLongPress')"
|
||||
>
|
||||
<div :class="{ 'pointer-events-none': selectMode }">
|
||||
<Instance
|
||||
:instance="instance"
|
||||
:disabled="selectMode"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
:class="{
|
||||
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
|
||||
}"
|
||||
@contextmenu.prevent.stop="
|
||||
(event: MouseEvent) =>
|
||||
emit('handleContextMenu', event, instance.id, props.sectionKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 z-10 transition-opacity"
|
||||
:class="
|
||||
selectMode && selectedInstanceIds.has(instance.id)
|
||||
? ''
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
"
|
||||
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
|
||||
>
|
||||
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectMode"
|
||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="
|
||||
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
|
||||
"
|
||||
>
|
||||
<Button type="quiet" size="2xs" circular icon-only><slot name="moreIcon" /> </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
v-if="instances.length === 0"
|
||||
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu ref="groupOptions" @option-clicked="handleGroupOption">
|
||||
<template #new_instance>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newInstance) }}
|
||||
</template>
|
||||
<template #add_instances>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addToGroup) }}
|
||||
</template>
|
||||
<template #edit_name>
|
||||
<EditIcon />
|
||||
{{ formatMessage(messages.editGroupName) }}
|
||||
</template>
|
||||
<template #delete_group>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteGroup) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<NewModal
|
||||
ref="confirmDeleteGroupModal"
|
||||
:header="formatMessage(messages.deleteGroup)"
|
||||
fade="danger"
|
||||
width="500px"
|
||||
>
|
||||
<p class="m-0 text-base text-[var(--color-text-default)]">
|
||||
{{ formatMessage(messages.deleteGroupDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface CrashLogFile {
|
||||
age: number
|
||||
filename: string
|
||||
log_type: 'InfoLog' | 'CrashReport' | 'JvmCrash' | 'LauncherLog'
|
||||
output?: string | null
|
||||
}
|
||||
|
||||
const RUN_ASSOCIATION_WINDOW_SECONDS = 3 * 60
|
||||
|
||||
export function shouldUseLogShareAutoAnalysis(settings: {
|
||||
ai_source: 'logshare' | 'custom'
|
||||
auto_upload: boolean
|
||||
no_storage?: boolean
|
||||
}): boolean {
|
||||
return settings.ai_source === 'logshare' && settings.auto_upload
|
||||
}
|
||||
|
||||
function isRelevantLog(file: CrashLogFile): boolean {
|
||||
const lower = file.filename.toLowerCase()
|
||||
if (file.log_type === 'CrashReport') return lower.endsWith('.txt')
|
||||
if (file.log_type === 'JvmCrash') return lower.startsWith('hs_err') && lower.endsWith('.log')
|
||||
return ['latest.log', 'debug.log', 'launcher_log.txt', 'latest_stdout.log'].includes(lower)
|
||||
}
|
||||
|
||||
export function selectCrashLogFiles(files: CrashLogFile[]): CrashLogFile[] {
|
||||
const sorted = files.filter(isRelevantLog).sort((left, right) => right.age - left.age)
|
||||
const anchor = sorted[0]?.age
|
||||
if (anchor === undefined) return []
|
||||
|
||||
const singletonTypes = new Set<CrashLogFile['log_type']>()
|
||||
return sorted.filter((file) => {
|
||||
if (anchor - file.age > RUN_ASSOCIATION_WINDOW_SECONDS) return false
|
||||
if (file.log_type !== 'CrashReport' && file.log_type !== 'JvmCrash') return true
|
||||
if (singletonTypes.has(file.log_type)) return false
|
||||
singletonTypes.add(file.log_type)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function crashLogKey(file: CrashLogFile): string {
|
||||
return `${file.log_type}:${file.filename}`
|
||||
}
|
||||
|
||||
export function crashLogLabel(file: CrashLogFile): string {
|
||||
return file.log_type === 'CrashReport' ? `crash-reports/${file.filename}` : file.filename
|
||||
}
|
||||
|
||||
export function preferredCrashLogKey(files: CrashLogFile[]): string {
|
||||
const preferred =
|
||||
files.find((file) => file.log_type === 'CrashReport') ??
|
||||
files.find((file) => file.log_type === 'JvmCrash') ??
|
||||
files.find((file) => file.filename.toLowerCase() === 'latest.log') ??
|
||||
files[0]
|
||||
return preferred ? crashLogKey(preferred) : ''
|
||||
}
|
||||
|
||||
export function combineCrashLogs(files: CrashLogFile[]): string {
|
||||
return files
|
||||
.filter((file) => file.output)
|
||||
.map((file) => `===== ${crashLogLabel(file)} =====\n${file.output}`)
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
|
||||
for (const instance of allInstances.value) {
|
||||
if (instance.groups.includes(group)) {
|
||||
const newGroups = instance.groups.filter((g) => g !== group)
|
||||
await edit(instance.id, { groups: newGroups }).catch(() => {})
|
||||
await edit(instance.id, { groups: newGroups }).catch(handleError)
|
||||
instance.groups = newGroups
|
||||
}
|
||||
}
|
||||
@@ -167,7 +169,7 @@ async function confirm() {
|
||||
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
||||
|
||||
for (const instanceId of props.instanceIds) {
|
||||
await edit(instanceId, { groups }).catch(() => {})
|
||||
await edit(instanceId, { groups }).catch(handleError)
|
||||
}
|
||||
|
||||
emit('applied')
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<NewModal ref="modal" no-padding scrollable actions-divider max-width="560px" width="560px">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-[var(--color-text-primary)]">
|
||||
{{
|
||||
existingGroupName
|
||||
? formatMessage(messages.addToGroupTitle, { groupName: existingGroupName })
|
||||
: formatMessage(messages.header)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="!existingGroupName">
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<label for="new-group-name" class="font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.groupName) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="new-group-name"
|
||||
ref="groupNameInput"
|
||||
v-model="newGroupName"
|
||||
:placeholder="formatMessage(messages.groupNamePlaceholder)"
|
||||
:maxlength="32"
|
||||
@click="groupNameInput?.select()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
</template>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
v-model="newGroupSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchInstance)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newGroupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.noInstancesFound) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in newGroupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
class="!size-[2rem] !rounded-md"
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
|
||||
instance.name
|
||||
}}</span>
|
||||
<TagItem v-if="instance.groups && instance.groups.length > 0" class="shrink-0">
|
||||
{{ getGroupName(instance.groups[0]) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||
@click="toggleNewGroupInstance(instance.id)"
|
||||
>
|
||||
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
|
||||
{{
|
||||
formatMessage(
|
||||
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="outlined" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="existingGroupName ? !canSaveGroups : !canCreateGroup"
|
||||
@click="existingGroupName ? handleSaveGroups() : handleCreateGroup()"
|
||||
>
|
||||
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
|
||||
<CheckIcon v-else-if="existingGroupName" />
|
||||
<PlusIcon v-else />
|
||||
{{ formatMessage(existingGroupName ? messages.saveChanges : messages.createGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, NewModal, StyledInput, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { create_group, list_groups, set_group_memberships } from '@/helpers/instance-groups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
existingGroupName?: string
|
||||
existingGroupId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'applied'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instances.batch-edit-groups.header',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
addToGroupTitle: {
|
||||
id: 'app.instances.batch-edit-groups.add-to-group-title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
},
|
||||
groupName: {
|
||||
id: 'app.instances.batch-edit-groups.group-name',
|
||||
defaultMessage: 'Group name',
|
||||
},
|
||||
groupNamePlaceholder: {
|
||||
id: 'app.instances.batch-edit-groups.group-name-placeholder',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
searchInstance: {
|
||||
id: 'app.instances.batch-edit-groups.search-instance',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
noInstancesFound: {
|
||||
id: 'app.instances.batch-edit-groups.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
add: {
|
||||
id: 'app.instances.batch-edit-groups.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
added: {
|
||||
id: 'app.instances.batch-edit-groups.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.instances.batch-edit-groups.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createGroup: {
|
||||
id: 'app.instances.batch-edit-groups.create',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
pinnedGroupName: {
|
||||
id: 'app.instances.group.pinned',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'app.instances.batch-edit-groups.save-changes',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const newGroupName = ref('')
|
||||
const newGroupSearch = ref('')
|
||||
const creatingGroup = ref(false)
|
||||
const allInstances = ref<GameInstance[]>([])
|
||||
const groupNameMap = ref(new Map<string, string>())
|
||||
const selectedNewGroupInstanceIds = ref(new Set<string>())
|
||||
|
||||
const canCreateGroup = computed(() => newGroupName.value.trim().length > 0)
|
||||
|
||||
const canSaveGroups = computed(() => {
|
||||
if (!props.existingGroupName) return false
|
||||
const groupId = props.existingGroupId
|
||||
if (!groupId) return false
|
||||
return allInstances.value.some((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId)
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
})
|
||||
|
||||
const newGroupInstances = computed(() => {
|
||||
const search = newGroupSearch.value.toLowerCase()
|
||||
return allInstances.value.filter((instance) => {
|
||||
if (!search) return true
|
||||
return instance.name.toLowerCase().includes(search)
|
||||
})
|
||||
})
|
||||
|
||||
function getGroupName(groupId: string) {
|
||||
if (groupId === FAVORITES_GROUP_ID) {
|
||||
return formatMessage(messages.pinnedGroupName)
|
||||
}
|
||||
return groupNameMap.value.get(groupId) || groupId
|
||||
}
|
||||
|
||||
function show(ids?: string[]) {
|
||||
newGroupSearch.value = ''
|
||||
creatingGroup.value = false
|
||||
Promise.all([list(), list_groups()]).then(([instances, groups]) => {
|
||||
allInstances.value = instances as GameInstance[]
|
||||
const map = new Map<string, string>()
|
||||
for (const g of groups) {
|
||||
map.set(g.id, g.name)
|
||||
}
|
||||
groupNameMap.value = map
|
||||
|
||||
if (!props.existingGroupName) {
|
||||
const customGroups = groups.filter((g: { id: string }) => g.id !== 'group:favorites')
|
||||
let groupNumber = customGroups.length + 1
|
||||
const existingNames = new Set(groups.map((g: { name: string }) => g.name.toLowerCase()))
|
||||
while (existingNames.has(`group ${groupNumber}`)) {
|
||||
groupNumber++
|
||||
}
|
||||
newGroupName.value = `Group ${groupNumber}`
|
||||
}
|
||||
|
||||
if (props.existingGroupName) {
|
||||
const groupId = props.existingGroupId
|
||||
selectedNewGroupInstanceIds.value = new Set(
|
||||
allInstances.value.filter((i) => (i.groups || []).includes(groupId || '')).map((i) => i.id),
|
||||
)
|
||||
} else {
|
||||
selectedNewGroupInstanceIds.value = new Set(ids ?? props.instanceIds)
|
||||
}
|
||||
})
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function toggleNewGroupInstance(instanceId: string) {
|
||||
const next = new Set(selectedNewGroupInstanceIds.value)
|
||||
if (next.has(instanceId)) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.add(instanceId)
|
||||
}
|
||||
selectedNewGroupInstanceIds.value = next
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name || selectedNewGroupInstanceIds.value.size === 0) return
|
||||
|
||||
creatingGroup.value = true
|
||||
|
||||
const group = await create_group(name).catch(() => null)
|
||||
if (!group) {
|
||||
creatingGroup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedNewGroupInstanceIds.value.size > 0) {
|
||||
const updates = [...selectedNewGroupInstanceIds.value].map((instanceId) => ({
|
||||
instance_id: instanceId,
|
||||
group_ids: [group.id],
|
||||
}))
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
}
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
async function handleSaveGroups() {
|
||||
if (!props.existingGroupName) return
|
||||
|
||||
creatingGroup.value = true
|
||||
const groupId = props.existingGroupId
|
||||
|
||||
const changedInstances = allInstances.value.filter((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId || '')
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
|
||||
const updates = changedInstances.map((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
return {
|
||||
instance_id: instance.id,
|
||||
group_ids: isSelected ? [groupId!] : [],
|
||||
}
|
||||
})
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileArchiveIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
RefreshCwIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -20,8 +21,10 @@ import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
|
||||
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||
import SyncSettings from '@/components/ui/instance_settings/SyncSettings.vue'
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
@@ -52,8 +55,9 @@ let serverMetadataGeneration = 0
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
function hide(onAfterClose?: () => void) {
|
||||
tabbedModal.value?.hide()
|
||||
if (onAfterClose) nextTick(onAfterClose)
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
@@ -105,6 +109,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.backups',
|
||||
defaultMessage: 'Backups',
|
||||
}),
|
||||
icon: FileArchiveIcon,
|
||||
content: BackupSettings,
|
||||
},
|
||||
{
|
||||
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
|
||||
name: defineMessage({
|
||||
@@ -138,6 +150,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.sync',
|
||||
defaultMessage: 'Sync',
|
||||
}),
|
||||
icon: RefreshCwIcon,
|
||||
content: SyncSettings,
|
||||
},
|
||||
])
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
|
||||
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
|
||||
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
||||
<button data-onboarding-skip :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -170,7 +170,7 @@ const {
|
||||
top: auto !important;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
left: var(--left-bar-width, 4rem) !important;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -252,6 +252,42 @@ export const onboardingMessages = defineMessages({
|
||||
id: 'app.onboarding.instance-tabs.description',
|
||||
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
|
||||
},
|
||||
instanceContentTabTitle: {
|
||||
id: 'app.onboarding.instance-content-tab.title',
|
||||
defaultMessage: 'Content',
|
||||
},
|
||||
instanceContentTabDescription: {
|
||||
id: 'app.onboarding.instance-content-tab.description',
|
||||
defaultMessage:
|
||||
'Manage this instance’s mods, resource packs, and shaders here. Search, enable or disable entries, check for updates, and browse for more.',
|
||||
},
|
||||
instanceFilesTabTitle: {
|
||||
id: 'app.onboarding.instance-files-tab.title',
|
||||
defaultMessage: 'Files',
|
||||
},
|
||||
instanceFilesTabDescription: {
|
||||
id: 'app.onboarding.instance-files-tab.description',
|
||||
defaultMessage:
|
||||
'Browse the instance folder and open configs or logs in the built-in editor without leaving the launcher.',
|
||||
},
|
||||
instanceWorldsTabTitle: {
|
||||
id: 'app.onboarding.instance-worlds-tab.title',
|
||||
defaultMessage: 'Worlds',
|
||||
},
|
||||
instanceWorldsTabDescription: {
|
||||
id: 'app.onboarding.instance-worlds-tab.description',
|
||||
defaultMessage:
|
||||
'Manage saved worlds: back them up, rename, edit world settings, and open the world folder.',
|
||||
},
|
||||
instanceLogsTabTitle: {
|
||||
id: 'app.onboarding.instance-logs-tab.title',
|
||||
defaultMessage: 'Logs',
|
||||
},
|
||||
instanceLogsTabDescription: {
|
||||
id: 'app.onboarding.instance-logs-tab.description',
|
||||
defaultMessage:
|
||||
'Read the instance’s live logs to trace crashes and errors without digging through files.',
|
||||
},
|
||||
labTitle: {
|
||||
id: 'app.onboarding.lab.title',
|
||||
defaultMessage: 'Useful tools, built in',
|
||||
@@ -381,7 +417,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
inspect(
|
||||
'account-login',
|
||||
'account-entry',
|
||||
'sidebar-accounts',
|
||||
onboardingMessages.accountLoginTitle,
|
||||
onboardingMessages.accountLoginDescription,
|
||||
),
|
||||
@@ -391,11 +427,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.announcementTitle,
|
||||
onboardingMessages.announcementDescription,
|
||||
),
|
||||
inspect(
|
||||
step(
|
||||
'create-starlight',
|
||||
'create-instance',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.createStarlightTitle,
|
||||
onboardingMessages.createStarlightDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
step(
|
||||
'discover-navigation',
|
||||
@@ -405,7 +445,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.discoverDescription,
|
||||
onboardingMessages.clickDiscover,
|
||||
),
|
||||
control('nav-discover', '/browse/modpack'),
|
||||
control('nav-discover', '/browse'),
|
||||
),
|
||||
inspect(
|
||||
'discover-content',
|
||||
@@ -505,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.instanceActionsTitle,
|
||||
onboardingMessages.instanceActionsDescription,
|
||||
),
|
||||
step(
|
||||
'instance-tabs',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.instanceTabsTitle,
|
||||
onboardingMessages.instanceTabsDescription,
|
||||
onboardingMessages.finishArea,
|
||||
inspect(
|
||||
'instance-content-tab',
|
||||
'instance-tab-content',
|
||||
onboardingMessages.instanceContentTabTitle,
|
||||
onboardingMessages.instanceContentTabDescription,
|
||||
),
|
||||
{ targetId: 'instance-tabs' },
|
||||
inspect(
|
||||
'instance-files-tab',
|
||||
'instance-tab-files',
|
||||
onboardingMessages.instanceFilesTabTitle,
|
||||
onboardingMessages.instanceFilesTabDescription,
|
||||
),
|
||||
inspect(
|
||||
'instance-worlds-tab',
|
||||
'instance-tab-worlds',
|
||||
onboardingMessages.instanceWorldsTabTitle,
|
||||
onboardingMessages.instanceWorldsTabDescription,
|
||||
),
|
||||
inspect(
|
||||
'instance-logs-tab',
|
||||
'instance-tab-logs',
|
||||
onboardingMessages.instanceLogsTabTitle,
|
||||
onboardingMessages.instanceLogsTabDescription,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -146,6 +146,14 @@ export function useOnboardingTour(
|
||||
|
||||
function scheduleMissingTargetRetry(stepId: string) {
|
||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
|
||||
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
|
||||
if (step.value.interaction === 'inspect') {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
@@ -271,8 +279,11 @@ export function useOnboardingTour(
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!visible.value) return
|
||||
const clickedElement = event.target instanceof Element ? event.target : null
|
||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||
|
||||
// 跳过/关闭按钮由其自身处理,不在全局点击里推进
|
||||
if (clickedElement?.closest('[data-onboarding-skip]')) return
|
||||
|
||||
// inspect 步:点击任意位置(含导览气泡本身)都继续下一步
|
||||
if (step.value.interaction === 'inspect') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
@@ -280,11 +291,14 @@ export function useOnboardingTour(
|
||||
return
|
||||
}
|
||||
|
||||
// 非 inspect 步:点在导览 UI 自身时不处理
|
||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||
|
||||
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
||||
if (clickedElement && handleBranchClick(clickedElement)) return
|
||||
if (!targetElement.value?.contains(event.target as Node)) return
|
||||
|
||||
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
|
||||
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
|
||||
waitingForRoute.value = true
|
||||
return
|
||||
}
|
||||
@@ -301,7 +315,7 @@ export function useOnboardingTour(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||
if (path !== step.value.expectedPath) return
|
||||
if (!path.startsWith(step.value.expectedPath)) return
|
||||
waitingForRoute.value = false
|
||||
void advance()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
const props = defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
selectionKey: string
|
||||
selected: boolean
|
||||
selectionActive: boolean
|
||||
activeDragged: boolean
|
||||
canDrag: boolean
|
||||
showInstanceName: boolean
|
||||
highlighted: boolean
|
||||
copied: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'copy' | 'edit' | 'toggle-selection'): void
|
||||
(e: 'more', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const card = ref<HTMLElement>()
|
||||
const thumbnail = useImageThumbnail(
|
||||
() => ({
|
||||
instanceId: props.screenshot.instance_id,
|
||||
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||
path: props.screenshot.path,
|
||||
}),
|
||||
512,
|
||||
() => props.screenshot.modified_at,
|
||||
)
|
||||
const image = ref<HTMLImageElement>()
|
||||
const imageReady = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugImage = useDebugLogger('Screenshots:Card')
|
||||
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
let loadStartedAt = performance.now()
|
||||
let loadGeneration = 0
|
||||
const messages = defineMessages({
|
||||
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
|
||||
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
|
||||
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
|
||||
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
|
||||
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
|
||||
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
|
||||
})
|
||||
|
||||
const sensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
useDraggable({
|
||||
id: computed(() => `screenshot:${props.selectionKey}`),
|
||||
element: card,
|
||||
disabled: computed(() => !props.canDrag),
|
||||
sensors,
|
||||
data: computed(() => ({
|
||||
selectionKey: props.selectionKey,
|
||||
instanceId: props.screenshot.instance_id,
|
||||
})),
|
||||
})
|
||||
|
||||
function activate(event: MouseEvent | KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
emit('activate', event)
|
||||
}
|
||||
|
||||
async function markImageLoaded() {
|
||||
const loadedImage = image.value
|
||||
const loadedUrl = thumbnail.value
|
||||
const generation = loadGeneration
|
||||
if (!loadedImage) return
|
||||
|
||||
try {
|
||||
await loadedImage.decode()
|
||||
} catch {
|
||||
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
if (
|
||||
generation !== loadGeneration ||
|
||||
image.value !== loadedImage ||
|
||||
thumbnail.value !== loadedUrl
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasReady = imageReady.value
|
||||
imageReady.value = true
|
||||
if (!wasReady) {
|
||||
debugImage('image loaded', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
naturalWidth: image.value?.naturalWidth,
|
||||
naturalHeight: image.value?.naturalHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function markImageFailed(event: Event) {
|
||||
debugImage('image failed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugImage('mounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
complete: image.value?.complete,
|
||||
})
|
||||
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadGeneration += 1
|
||||
debugImage('unmounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loaded: imageReady.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(thumbnail, (url, previousUrl) => {
|
||||
loadGeneration += 1
|
||||
loadStartedAt = performance.now()
|
||||
imageReady.value = false
|
||||
debugImage('source changed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
previousUrl,
|
||||
url,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<article
|
||||
ref="card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
:class="{
|
||||
'!border-brand brightness-110': selected,
|
||||
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||
'opacity-50': activeDragged,
|
||||
'cursor-grab active:cursor-grabbing': canDrag,
|
||||
}"
|
||||
data-screenshot-card
|
||||
:data-screenshot-id="screenshot.id"
|
||||
:data-selection-key="selectionKey"
|
||||
:aria-label="
|
||||
selectionActive
|
||||
? formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
: screenshot.file_name
|
||||
"
|
||||
:aria-pressed="selectionActive ? selected : undefined"
|
||||
@click="activate"
|
||||
@contextmenu.prevent.stop="emit('more', $event)"
|
||||
@keydown="activate"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||
:aria-label="
|
||||
formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="emit('toggle-selection')"
|
||||
>
|
||||
<span
|
||||
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="
|
||||
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
|
||||
"
|
||||
>
|
||||
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||
</span>
|
||||
</button>
|
||||
<img
|
||||
v-if="thumbnail"
|
||||
ref="image"
|
||||
:src="thumbnail"
|
||||
:alt="screenshot.file_name"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
|
||||
:class="imageReady ? 'opacity-100' : 'opacity-0'"
|
||||
@load="markImageLoaded"
|
||||
@error="markImageFailed"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 bg-surface-4"
|
||||
:class="{ 'animate-pulse': !imageReady }"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||
{{ screenshot.file_name }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectionActive"
|
||||
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.edit)"
|
||||
:label="formatMessage(messages.edit)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
:label="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<CheckIcon v-if="copied" class="text-green" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moreActions)"
|
||||
:label="formatMessage(messages.moreActions)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('more', $event)"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screenshot-card-fade {
|
||||
transition: opacity 350ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import ScreenshotDragPreview from './drag-preview.vue'
|
||||
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ScreenshotDragGatherItem[]
|
||||
target: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const gathered = ref(false)
|
||||
const finishing = ref(false)
|
||||
let animationFrame: number | undefined
|
||||
let completionTimer: number | undefined
|
||||
|
||||
const itemContainerStyles = computed(() =>
|
||||
props.items.map((_, index) => ({
|
||||
left: `${props.target.x}px`,
|
||||
top: `${props.target.y}px`,
|
||||
zIndex: `${index}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const itemStyles = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const originX = item.rect.left - props.target.x
|
||||
const originY = item.rect.top - props.target.y
|
||||
|
||||
return {
|
||||
width: `${item.rect.width}px`,
|
||||
height: `${item.rect.height}px`,
|
||||
transform: gathered.value
|
||||
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
|
||||
: `translate3d(${originX}px, ${originY}px, 0)`,
|
||||
transitionDuration: `${gatherDuration}ms`,
|
||||
transitionDelay: `${Math.min(index, 8) * 10}ms`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
gathered.value = true
|
||||
completionTimer = window.setTimeout(
|
||||
() => {
|
||||
finishing.value = true
|
||||
emit('complete')
|
||||
},
|
||||
Math.max(gatherDuration - 250, 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
if (completionTimer !== undefined) {
|
||||
clearTimeout(completionTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
|
||||
:class="finishing ? 'opacity-0' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.selectionKey"
|
||||
class="fixed"
|
||||
:style="itemContainerStyles[index]"
|
||||
>
|
||||
<div
|
||||
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||
:style="itemStyles[index]"
|
||||
>
|
||||
<ScreenshotDragPreview :screenshot="item.screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import { gatherDuration } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const thumbnail = useImageThumbnail(
|
||||
() => ({
|
||||
instanceId: props.screenshot.instance_id,
|
||||
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||
path: props.screenshot.path,
|
||||
}),
|
||||
512,
|
||||
() => props.screenshot.modified_at,
|
||||
)
|
||||
const showGatheredCount = ref(false)
|
||||
let countTimer: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
showGatheredCount.value = true
|
||||
return
|
||||
}
|
||||
|
||||
countTimer = window.setTimeout(() => {
|
||||
showGatheredCount.value = true
|
||||
countTimer = undefined
|
||||
}, gatherDuration - 150)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countTimer !== undefined) {
|
||||
clearTimeout(countTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="relative w-full select-none">
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
|
||||
>
|
||||
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
|
||||
<TagItem
|
||||
v-if="count > 1"
|
||||
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
>
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import ScreenshotCard from './card.vue'
|
||||
import ScreenshotSection from './section.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
screenshots: InstanceScreenshot[]
|
||||
renderedScreenshots?: InstanceScreenshot[]
|
||||
virtualGridHeight?: number
|
||||
virtualGridTop?: number
|
||||
selectedKeys: ReadonlySet<string>
|
||||
selectionActive: boolean
|
||||
activeDraggedKeys: ReadonlySet<string>
|
||||
showDropOutline: boolean
|
||||
canDrag: boolean
|
||||
dropInstanceId?: string
|
||||
dropCustomGroup?: boolean
|
||||
dropCustomGroupId?: string
|
||||
showInstanceName: boolean
|
||||
highlightedScreenshotId?: string
|
||||
copiedScreenshotIds: ReadonlySet<string>
|
||||
forceOpen: boolean
|
||||
hideHeader?: boolean
|
||||
editableTitle?: boolean
|
||||
startEditingTitle?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>()
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
const dropTarget = ref<HTMLElement>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Group')
|
||||
const messages = defineMessages({
|
||||
emptyGroup: {
|
||||
id: 'app.screenshots.group.empty',
|
||||
defaultMessage: 'Drag and drop to add screenshots.',
|
||||
},
|
||||
})
|
||||
|
||||
const shouldShowGrid = computed(() =>
|
||||
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
|
||||
)
|
||||
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
|
||||
const renderGrid = ref(shouldShowGrid.value)
|
||||
const virtualGridStyle = computed(() =>
|
||||
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
|
||||
)
|
||||
const visibleGridStyle = computed(() =>
|
||||
props.virtualGridTop === undefined
|
||||
? undefined
|
||||
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||
)
|
||||
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
async function logGeometry(reason: string) {
|
||||
await nextTick()
|
||||
const rect = dropTarget.value?.getBoundingClientRect()
|
||||
debugLayout(reason, {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
collapsed: collapsed.value,
|
||||
shouldShowGrid: shouldShowGrid.value,
|
||||
renderGrid: renderGrid.value,
|
||||
screenshotCount: props.screenshots.length,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
|
||||
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
|
||||
virtualGridHeight: props.virtualGridHeight,
|
||||
virtualGridTop: props.virtualGridTop,
|
||||
actual: rect
|
||||
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.renderedScreenshots ?? props.screenshots,
|
||||
(screenshots) => {
|
||||
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||
void logGeometry('rendered screenshots changed')
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.virtualGridHeight, props.virtualGridTop] as const,
|
||||
() => void logGeometry('virtual grid geometry changed'),
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldShowGrid,
|
||||
(showGrid, previouslyShown) => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
void logGeometry('grid shown')
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
renderGrid.value = false
|
||||
return
|
||||
}
|
||||
unmountGridTimeout = setTimeout(() => {
|
||||
renderGrid.value = false
|
||||
void logGeometry('grid unmounted after collapse')
|
||||
unmountGridTimeout = undefined
|
||||
}, 300)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', { id: props.id, title: props.title })
|
||||
void logGeometry('mounted geometry')
|
||||
if (dropTarget.value) {
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const rect = entry?.target.getBoundingClientRect()
|
||||
debugLayout('actual size changed', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
expectedHeight:
|
||||
(props.hideHeader ? 0 : 40) +
|
||||
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
|
||||
12,
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(dropTarget.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
})
|
||||
resizeObserver?.disconnect()
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
|
||||
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
useDroppable({
|
||||
id: computed(() => `screenshot-group:${props.id}`),
|
||||
element: dropTarget,
|
||||
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
|
||||
data: computed(() =>
|
||||
props.dropCustomGroup
|
||||
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
|
||||
: { groupId: props.id, instanceId: props.dropInstanceId },
|
||||
),
|
||||
})
|
||||
|
||||
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropTarget"
|
||||
class="group/instance-container relative select-none pb-3 transition-colors"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="!opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="!opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropOutline"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
|
||||
/>
|
||||
</Transition>
|
||||
<ScreenshotSection
|
||||
v-model:collapsed="collapsed"
|
||||
:title="title"
|
||||
:count="screenshots.length"
|
||||
:force-open="forceOpen"
|
||||
:hide-header="hideHeader"
|
||||
:editable="editableTitle"
|
||||
:start-editing="startEditingTitle"
|
||||
:max-title-length="maxTitleLength"
|
||||
:validate-title="validateTitle"
|
||||
:on-title-change="onTitleChange"
|
||||
>
|
||||
<template #actions="{ startEditing }">
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<div
|
||||
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
|
||||
:style="visibleGridStyle"
|
||||
>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in visibleScreenshots"
|
||||
:key="getSelectionKey(screenshot)"
|
||||
:screenshot="screenshot"
|
||||
:selection-key="getSelectionKey(screenshot)"
|
||||
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||
:can-drag="canDrag"
|
||||
:show-instance-name="showInstanceName"
|
||||
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||
@activate="(event) => emit('activate', screenshot, event)"
|
||||
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||
@copy="emit('copy', screenshot)"
|
||||
@edit="emit('edit', screenshot)"
|
||||
@more="(event) => emit('more', screenshot, event)"
|
||||
/>
|
||||
<p
|
||||
v-if="screenshots.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
InlineEditableText,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
collapsed?: boolean
|
||||
forceOpen?: boolean
|
||||
hideHeader?: boolean
|
||||
editable?: boolean
|
||||
startEditing?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
forceOpen: false,
|
||||
hideHeader: false,
|
||||
editable: false,
|
||||
startEditing: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:collapsed', collapsed: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Section')
|
||||
const accordion = ref<InstanceType<typeof Accordion>>()
|
||||
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const titleModel = ref(props.title)
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
|
||||
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
debugLayout('toggle requested', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
isOpen: accordion.value?.isOpen,
|
||||
})
|
||||
if (accordion.value?.isOpen) {
|
||||
accordion.value.close()
|
||||
} else {
|
||||
accordion.value?.open()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
debugLayout('opened', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', false)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
debugLayout('closed', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
collapsed: props.collapsed,
|
||||
hideHeader: props.hideHeader,
|
||||
forceOpen: props.forceOpen,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', { title: props.title, count: props.count })
|
||||
})
|
||||
|
||||
async function startTitleEditing() {
|
||||
if (!props.editable) return
|
||||
await titleInput.value?.startEditing()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
titleModel.value = title
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.startEditing,
|
||||
async (shouldStartEditing) => {
|
||||
if (!shouldStartEditing) return
|
||||
await nextTick()
|
||||
await startTitleEditing()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full flex-col">
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
>
|
||||
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
|
||||
:aria-expanded="accordion?.isOpen"
|
||||
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
|
||||
:class="{ 'rotate-180': accordion?.isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="editable"
|
||||
ref="titleInput"
|
||||
v-model="titleModel"
|
||||
activation-mode="manual"
|
||||
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||
max-width="24rem"
|
||||
icon-text-class="select-none"
|
||||
:max-length="maxTitleLength"
|
||||
:validate="validateTitle"
|
||||
:on-change="onTitleChange"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<slot name="actions" :start-editing="startTitleEditing" />
|
||||
</div>
|
||||
<Accordion
|
||||
ref="accordion"
|
||||
:open-by-default="hideHeader || !props.collapsed"
|
||||
:force-open="forceOpen"
|
||||
overflow-visible
|
||||
class="w-full"
|
||||
@on-open="handleOpen"
|
||||
@on-close="handleClose"
|
||||
>
|
||||
<div class="mt-2.5">
|
||||
<slot />
|
||||
</div>
|
||||
</Accordion>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
const sort = defineModel<string>('sort', { required: true })
|
||||
const group = defineModel<string>('group', { required: true })
|
||||
|
||||
defineProps<{
|
||||
sortOptions: ComboboxOption<string>[]
|
||||
groupOptions: ComboboxOption<string>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'new-group'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
|
||||
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
|
||||
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
|
||||
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="min-w-[16rem] flex-1"
|
||||
/>
|
||||
<Button @click="emit('new-group')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
v-model="sort"
|
||||
class="w-max"
|
||||
:options="sortOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<ArrowUpDownIcon
|
||||
class="size-5 text-[var(--color-text-default)]"
|
||||
:aria-label="formatMessage(messages.sortBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="group"
|
||||
class="w-max"
|
||||
:options="groupOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<GridIcon
|
||||
class="size-5 text-[var(--color-text-default)]"
|
||||
:aria-label="formatMessage(messages.groupBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
import { onBeforeUnmount, type Ref, ref } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ActiveScreenshotDrag = {
|
||||
primarySelectionKey: string
|
||||
selectionKeys: string[]
|
||||
}
|
||||
|
||||
export const gatherDuration = 500
|
||||
|
||||
export type ScreenshotDragGatherItem = {
|
||||
screenshot: InstanceScreenshot
|
||||
selectionKey: string
|
||||
rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||
}
|
||||
|
||||
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
|
||||
const items = ref<ScreenshotDragGatherItem[]>([])
|
||||
const target = ref<Point>({ x: 0, y: 0 })
|
||||
const targetOffset = ref<Point>({ x: 0, y: 0 })
|
||||
const isGathering = ref(false)
|
||||
let cleanupTimer: number | undefined
|
||||
|
||||
const clearCleanupTimer = () => {
|
||||
if (cleanupTimer !== undefined) {
|
||||
clearTimeout(cleanupTimer)
|
||||
cleanupTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
clearCleanupTimer()
|
||||
items.value = []
|
||||
targetOffset.value = { x: 0, y: 0 }
|
||||
isGathering.value = false
|
||||
}
|
||||
|
||||
const updateTarget = (pointer: Point) => {
|
||||
target.value = {
|
||||
x: pointer.x + targetOffset.value.x,
|
||||
y: pointer.y + targetOffset.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
|
||||
clear()
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
|
||||
|
||||
const screenshotCards = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
|
||||
)
|
||||
const sourceCard = screenshotCards.find(
|
||||
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
|
||||
)
|
||||
|
||||
if (sourceCard) {
|
||||
const sourceRect = sourceCard.getBoundingClientRect()
|
||||
targetOffset.value = {
|
||||
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
|
||||
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
|
||||
}
|
||||
}
|
||||
|
||||
const screenshotsByKey = new Map(
|
||||
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
|
||||
)
|
||||
items.value = drag.selectionKeys.flatMap((selectionKey) => {
|
||||
const screenshot = screenshotsByKey.get(selectionKey)
|
||||
const card = screenshotCards.find(
|
||||
(candidate) =>
|
||||
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
|
||||
)
|
||||
if (!screenshot || !card) return []
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
return [
|
||||
{
|
||||
screenshot,
|
||||
selectionKey,
|
||||
rect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
updateTarget(pointer)
|
||||
isGathering.value = items.value.length > 0
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
isGathering.value = false
|
||||
clearCleanupTimer()
|
||||
cleanupTimer = window.setTimeout(() => {
|
||||
items.value = []
|
||||
cleanupTimer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearCleanupTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
target,
|
||||
isGathering,
|
||||
start,
|
||||
updateTarget,
|
||||
clear,
|
||||
finish,
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { appDataDir, join } from '@tauri-apps/api/path'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
@@ -41,6 +41,19 @@ import {
|
||||
parseCustomAccentColor,
|
||||
} from '@/store/theme.ts'
|
||||
|
||||
import {
|
||||
buildFontFamilyOptions,
|
||||
canonicalFontFamily,
|
||||
DEFAULT_MONO_FONT_STACK,
|
||||
DEFAULT_UI_FONT_STACK,
|
||||
type FontFamilyOption,
|
||||
type FontFamilyOptionOrDivider,
|
||||
optionValueToFontSetting,
|
||||
resolveFontFamily,
|
||||
type SystemFontFamily,
|
||||
} from '@/helpers/font-family.ts'
|
||||
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
@@ -363,6 +376,63 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Hide the downloads button in the sidebar when there are no active download tasks.',
|
||||
},
|
||||
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
|
||||
fontsDescription: {
|
||||
id: 'app.appearance-settings.fonts.description',
|
||||
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
|
||||
},
|
||||
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
|
||||
uiFontDescription: {
|
||||
id: 'app.appearance-settings.fonts.ui-font-description',
|
||||
defaultMessage:
|
||||
'Used across the launcher interface. Only fonts installed on this system are listed.',
|
||||
},
|
||||
monoFontTitle: {
|
||||
id: 'app.appearance-settings.fonts.mono-font',
|
||||
defaultMessage: 'Monospace font',
|
||||
},
|
||||
monoFontDescription: {
|
||||
id: 'app.appearance-settings.fonts.mono-font-description',
|
||||
defaultMessage: 'Used for logs, the server console, and other monospace content.',
|
||||
},
|
||||
fontDefault: {
|
||||
id: 'app.appearance-settings.fonts.default',
|
||||
defaultMessage: 'Launcher default',
|
||||
},
|
||||
fontMonospace: {
|
||||
id: 'app.appearance-settings.fonts.monospace',
|
||||
defaultMessage: 'Monospace',
|
||||
},
|
||||
fontMissing: {
|
||||
id: 'app.appearance-settings.fonts.missing',
|
||||
defaultMessage: 'Not installed',
|
||||
},
|
||||
fontSearchPlaceholder: {
|
||||
id: 'app.appearance-settings.fonts.search-placeholder',
|
||||
defaultMessage: 'Search installed fonts',
|
||||
},
|
||||
fontNoResults: {
|
||||
id: 'app.appearance-settings.fonts.no-results',
|
||||
defaultMessage: 'No matching fonts',
|
||||
},
|
||||
fontLoadFailed: {
|
||||
id: 'app.appearance-settings.fonts.load-failed',
|
||||
defaultMessage: "Couldn't read the list of installed fonts.",
|
||||
},
|
||||
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
|
||||
fontReset: {
|
||||
id: 'app.appearance-settings.fonts.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
|
||||
fontUiPreviewSample: {
|
||||
id: 'app.appearance-settings.fonts.ui-preview-sample',
|
||||
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
|
||||
},
|
||||
fontMonoPreviewSample: {
|
||||
id: 'app.appearance-settings.fonts.mono-preview-sample',
|
||||
defaultMessage: 'const x = () => 0; // monospace',
|
||||
},
|
||||
})
|
||||
|
||||
const os = ref(await getOS())
|
||||
@@ -373,6 +443,84 @@ const customBackgroundPreview = computed(() =>
|
||||
: null,
|
||||
)
|
||||
|
||||
const systemFonts = ref<SystemFontFamily[]>([])
|
||||
const systemFontsLoading = ref(true)
|
||||
const systemFontsFailed = ref(false)
|
||||
|
||||
async function loadSystemFonts() {
|
||||
systemFontsLoading.value = true
|
||||
systemFontsFailed.value = false
|
||||
|
||||
try {
|
||||
systemFonts.value = await getSystemFontFamilies()
|
||||
} catch (error) {
|
||||
systemFontsFailed.value = true
|
||||
console.warn('Failed to read the list of installed fonts', error)
|
||||
} finally {
|
||||
systemFontsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
|
||||
const option = options.find(
|
||||
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
|
||||
)
|
||||
|
||||
return option?.label ?? ''
|
||||
}
|
||||
|
||||
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
|
||||
buildFontFamilyOptions(systemFonts.value, {
|
||||
defaultLabel: formatMessage(messages.fontDefault),
|
||||
missingLabel: formatMessage(messages.fontMissing),
|
||||
selected: settings.value.ui_font,
|
||||
}),
|
||||
)
|
||||
|
||||
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
|
||||
buildFontFamilyOptions(systemFonts.value, {
|
||||
defaultLabel: formatMessage(messages.fontDefault),
|
||||
missingLabel: formatMessage(messages.fontMissing),
|
||||
monospaceLabel: formatMessage(messages.fontMonospace),
|
||||
selected: settings.value.mono_font,
|
||||
groupMonospaced: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const uiFontSelection = computed({
|
||||
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
|
||||
set: (value: string) => {
|
||||
settings.value.ui_font = optionValueToFontSetting(value)
|
||||
},
|
||||
})
|
||||
|
||||
const monoFontSelection = computed({
|
||||
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
|
||||
set: (value: string) => {
|
||||
settings.value.mono_font = optionValueToFontSetting(value)
|
||||
},
|
||||
})
|
||||
|
||||
const uiFontSearchValue = computed(() =>
|
||||
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
|
||||
)
|
||||
const monoFontSearchValue = computed(() =>
|
||||
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
|
||||
)
|
||||
|
||||
const uiFontPreview = computed(() =>
|
||||
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
|
||||
)
|
||||
const monoFontPreview = computed(() =>
|
||||
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
|
||||
)
|
||||
|
||||
function updateUiScale(value: number) {
|
||||
const clamped = Math.min(200, Math.max(50, Math.round(value)))
|
||||
setUiScale(clamped)
|
||||
settings.value.ui_scale = clamped
|
||||
}
|
||||
|
||||
const accentColorOptions: Array<{
|
||||
value: AccentColor
|
||||
color: string
|
||||
@@ -483,6 +631,8 @@ watch(
|
||||
settings.value.transparent_background_blur,
|
||||
settings.value.sidebar_instance_count,
|
||||
settings.value.close_behavior,
|
||||
settings.value.ui_font,
|
||||
settings.value.mono_font,
|
||||
] as const,
|
||||
([
|
||||
path,
|
||||
@@ -493,6 +643,8 @@ watch(
|
||||
transparentBlur,
|
||||
sidebarInstanceCount,
|
||||
closeBehavior,
|
||||
uiFont,
|
||||
monoFont,
|
||||
]) => {
|
||||
themeStore.customBackgroundPath = path
|
||||
themeStore.customBackgroundBlur = blur
|
||||
@@ -503,10 +655,19 @@ watch(
|
||||
themeStore.setTransparentBackgroundClass()
|
||||
themeStore.sidebarInstanceCount = sidebarInstanceCount
|
||||
themeStore.closeBehavior = closeBehavior as CloseBehavior
|
||||
themeStore.uiFont = uiFont
|
||||
themeStore.monoFont = monoFont
|
||||
themeStore.setUiFont()
|
||||
themeStore.setMonoFont()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.scope === 'interface') {
|
||||
void loadSystemFonts()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
@@ -1050,7 +1211,7 @@ watch(
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
|
||||
"
|
||||
@click="setUiScale(preset)"
|
||||
@click="updateUiScale(preset)"
|
||||
>
|
||||
{{ preset }}%
|
||||
</button>
|
||||
@@ -1062,7 +1223,7 @@ watch(
|
||||
:max="200"
|
||||
:step="5"
|
||||
unit="%"
|
||||
@update:model-value="(value) => setUiScale(Number(value))"
|
||||
@update:model-value="(value) => updateUiScale(Number(value))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1142,6 +1303,118 @@ watch(
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection v-if="props.scope === 'interface'">
|
||||
<template #header>
|
||||
<h2
|
||||
id="settings-target-appearance-fonts"
|
||||
tabindex="-1"
|
||||
class="m-0 text-lg font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.fontsTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.fontsDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow stacked>
|
||||
<template #label>
|
||||
<span id="settings-target-appearance-ui-font" tabindex="-1">
|
||||
{{ formatMessage(messages.uiFontTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
|
||||
<template #control>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Combobox
|
||||
v-model="uiFontSelection"
|
||||
:options="uiFontOptions"
|
||||
searchable
|
||||
:search-value="uiFontSearchValue"
|
||||
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
|
||||
:no-options-message="formatMessage(messages.fontNoResults)"
|
||||
show-no-options-when-empty
|
||||
:disabled="systemFontsLoading"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="quiet"
|
||||
:disabled="settings.ui_font === null"
|
||||
@click="settings.ui_font = null"
|
||||
>
|
||||
{{ formatMessage(messages.fontReset) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary">
|
||||
{{ formatMessage(messages.fontPreviewLabel) }}
|
||||
</span>
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
|
||||
:style="{ fontFamily: uiFontPreview }"
|
||||
>
|
||||
{{ formatMessage(messages.fontUiPreviewSample) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow stacked>
|
||||
<template #label>
|
||||
<span id="settings-target-appearance-mono-font" tabindex="-1">
|
||||
{{ formatMessage(messages.monoFontTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
|
||||
<template #control>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Combobox
|
||||
v-model="monoFontSelection"
|
||||
:options="monoFontOptions"
|
||||
searchable
|
||||
:search-value="monoFontSearchValue"
|
||||
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
|
||||
:no-options-message="formatMessage(messages.fontNoResults)"
|
||||
show-no-options-when-empty
|
||||
:disabled="systemFontsLoading"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="quiet"
|
||||
:disabled="settings.mono_font === null"
|
||||
@click="settings.mono_font = null"
|
||||
>
|
||||
{{ formatMessage(messages.fontReset) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary">
|
||||
{{ formatMessage(messages.fontPreviewLabel) }}
|
||||
</span>
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
|
||||
:style="{ fontFamily: monoFontPreview }"
|
||||
>
|
||||
{{ formatMessage(messages.fontMonoPreviewSample) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<div
|
||||
v-if="systemFontsFailed"
|
||||
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
|
||||
>
|
||||
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
|
||||
<Button type="quiet" @click="loadSystemFonts">
|
||||
{{ formatMessage(messages.fontRetry) }}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection v-if="props.scope === 'content-downloads'">
|
||||
<SettingsRow>
|
||||
<template #label>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Button,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type BackupRepositoryStatus,
|
||||
getBackupRepositoryStatus,
|
||||
listBackupOperations,
|
||||
listenBackupProgress,
|
||||
moveBackupRepository,
|
||||
} from '@/helpers/instance-backup'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const filePicker = injectFilePicker()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const status = ref<BackupRepositoryStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const moving = ref(false)
|
||||
const movedBytes = ref(0)
|
||||
const moveTotalBytes = ref(0)
|
||||
const movingOperationId = ref<string | null>(null)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'settings.backups.repository.title', defaultMessage: 'Backup repository' },
|
||||
description: {
|
||||
id: 'settings.backups.repository.description',
|
||||
defaultMessage: 'Deduplicated instance backup data and its independent index database.',
|
||||
},
|
||||
location: { id: 'settings.backups.repository.location', defaultMessage: 'Location' },
|
||||
locationDescription: {
|
||||
id: 'settings.backups.repository.location-description',
|
||||
defaultMessage:
|
||||
'Changing the location moves the complete repository. Choose a new or empty folder.',
|
||||
},
|
||||
change: { id: 'settings.backups.repository.change', defaultMessage: 'Change folder' },
|
||||
moving: { id: 'settings.backups.repository.moving', defaultMessage: 'Moving repository...' },
|
||||
movingProgress: {
|
||||
id: 'settings.backups.repository.moving-progress',
|
||||
defaultMessage: 'Moving repository: {processed} of {total}',
|
||||
},
|
||||
usage: { id: 'settings.backups.repository.usage', defaultMessage: 'Storage usage' },
|
||||
usageDescription: {
|
||||
id: 'settings.backups.repository.usage-description',
|
||||
defaultMessage:
|
||||
'{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}.',
|
||||
},
|
||||
unavailable: {
|
||||
id: 'settings.backups.repository.unavailable',
|
||||
defaultMessage: 'Repository unavailable',
|
||||
},
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await getBackupRepositoryStatus()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLocation() {
|
||||
const selection = await filePicker.pickFolder?.()
|
||||
if (!selection) return
|
||||
moving.value = true
|
||||
movedBytes.value = 0
|
||||
moveTotalBytes.value = 0
|
||||
try {
|
||||
movingOperationId.value = await moveBackupRepository(selection.path)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
moving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
unlisten = await listenBackupProgress((event) => {
|
||||
if (event.operationType !== 'repository_move') return
|
||||
if (movingOperationId.value && event.operationId !== movingOperationId.value) return
|
||||
movingOperationId.value = event.operationId
|
||||
moving.value = !event.finalState
|
||||
movedBytes.value = event.processedBytes
|
||||
moveTotalBytes.value = event.totalBytes
|
||||
if (event.finalState) void refresh()
|
||||
})
|
||||
const activeMove = (await listBackupOperations(undefined, true)).find(
|
||||
(operation) => operation.operation_type === 'repository_move',
|
||||
)
|
||||
if (activeMove) {
|
||||
movingOperationId.value = activeMove.id
|
||||
moving.value = true
|
||||
movedBytes.value = activeMove.processed_bytes
|
||||
moveTotalBytes.value = activeMove.total_bytes
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => unlisten?.())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSection
|
||||
:title="formatMessage(messages.title)"
|
||||
:description="formatMessage(messages.description)"
|
||||
>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.location) }}</template>
|
||||
<template #description>
|
||||
<span class="break-all">{{ status?.path ?? '...' }}</span>
|
||||
<span class="mt-1 block">{{ formatMessage(messages.locationDescription) }}</span>
|
||||
</template>
|
||||
<template #control>
|
||||
<Button type="outlined" :disabled="loading || moving" @click="changeLocation"
|
||||
><FolderIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
moving && moveTotalBytes > 0
|
||||
? messages.movingProgress
|
||||
: moving
|
||||
? messages.moving
|
||||
: messages.change,
|
||||
{
|
||||
processed: formatBytes(movedBytes),
|
||||
total: formatBytes(moveTotalBytes),
|
||||
},
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow v-if="status">
|
||||
<template #label>{{ formatMessage(messages.usage) }}</template>
|
||||
<template #description>
|
||||
{{
|
||||
formatMessage(messages.usageDescription, {
|
||||
stored: formatBytes(status.stored_size),
|
||||
logical: formatBytes(status.logical_size),
|
||||
count: status.snapshot_count,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<Admonition
|
||||
v-if="status && !status.available"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.unavailable)"
|
||||
>
|
||||
{{ status.error }}
|
||||
</Admonition>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -6,6 +6,8 @@ import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
|
||||
import LogShareSettings from './LogShareSettings.vue'
|
||||
import SharedLogsSettings from './SharedLogsSettings.vue'
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
@@ -38,6 +40,26 @@ const messages = defineMessages({
|
||||
id: 'app.settings.defaults.maximize-window.unsupported',
|
||||
defaultMessage: 'Not supported on this operating system.',
|
||||
},
|
||||
windowTitleEnabled: {
|
||||
id: 'app.settings.defaults.window-title.enabled',
|
||||
defaultMessage: 'Custom Minecraft window title',
|
||||
},
|
||||
windowTitleEnabledDescription: {
|
||||
id: 'app.settings.defaults.window-title.enabled-description',
|
||||
defaultMessage: 'Allow instances to customize the Minecraft window title.',
|
||||
},
|
||||
defaultWindowTitle: {
|
||||
id: 'app.settings.defaults.window-title.default',
|
||||
defaultMessage: 'Default window title',
|
||||
},
|
||||
defaultWindowTitleDescription: {
|
||||
id: 'app.settings.defaults.window-title.default-description',
|
||||
defaultMessage: 'Used when an instance leaves its window title empty.',
|
||||
},
|
||||
defaultWindowTitlePlaceholder: {
|
||||
id: 'app.settings.defaults.window-title.default-placeholder',
|
||||
defaultMessage: 'Enter default window title...',
|
||||
},
|
||||
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
|
||||
widthDescription: {
|
||||
id: 'app.settings.defaults.width-description',
|
||||
@@ -212,6 +234,36 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>
|
||||
<span id="settings-target-defaults-window-title" tabindex="-1">
|
||||
{{ formatMessage(messages.windowTitleEnabled) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>{{
|
||||
formatMessage(messages.windowTitleEnabledDescription)
|
||||
}}</template>
|
||||
<template #control>
|
||||
<Toggle id="custom-window-title-enabled" v-model="settings.custom_window_title_enabled" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow stacked>
|
||||
<template #label>{{ formatMessage(messages.defaultWindowTitle) }}</template>
|
||||
<template #description>{{
|
||||
formatMessage(messages.defaultWindowTitleDescription)
|
||||
}}</template>
|
||||
<template #control>
|
||||
<StyledInput
|
||||
id="default-window-title"
|
||||
v-model="settings.default_window_title"
|
||||
:disabled="!settings.custom_window_title_enabled"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.defaultWindowTitlePlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
@@ -341,5 +393,7 @@ watch(
|
||||
</SettingsSection>
|
||||
|
||||
<CrashAnalysisAISettings />
|
||||
<LogShareSettings />
|
||||
<SharedLogsSettings />
|
||||
</div>
|
||||
</template>
|
||||
@@ -54,6 +54,26 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const featureFlagLabels: Record<FeatureFlag, string> = {
|
||||
project_background: '项目背景',
|
||||
page_path: '页面路径',
|
||||
worlds_tab: '世界标签页',
|
||||
worlds_in_home: '主页显示世界',
|
||||
server_project_qa: '服务器项目问答',
|
||||
show_version_environment_column: '显示版本环境列',
|
||||
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
|
||||
always_show_app_controls: '始终显示窗口控件',
|
||||
skip_non_essential_warnings: '跳过非必要警告',
|
||||
skip_unknown_pack_warning: '跳过未知整合包警告',
|
||||
i18n_debug: '国际化调试',
|
||||
show_instance_play_time: '显示实例游玩时间',
|
||||
page_transitions: '页面切换动画',
|
||||
advanced_filters_collapsed: '高级筛选默认折叠',
|
||||
multiplayer_in_app: '多人联机',
|
||||
auto_install_dependencies: '自动安装依赖',
|
||||
force_anniversary_celebration: '强制显示周年庆庆祝(调试)',
|
||||
}
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||
|
||||
@@ -85,7 +105,7 @@ watch(
|
||||
<template>
|
||||
<SettingsSection>
|
||||
<SettingsRow v-for="option in options" :key="option">
|
||||
<template #label>{{ option.replaceAll('_', ' ') }}</template>
|
||||
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
|
||||
<template #control>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineMessages,
|
||||
type KeyBinding,
|
||||
KeybindingChips,
|
||||
KeybindingModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getNavShortcutEnabled, setNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
|
||||
import { getQuickScrollEnabled, setQuickScrollEnabled } from '@/helpers/scroll-top-state'
|
||||
import { NAV_ACTIONS, SCROLL_ACTIONS, type ShortcutAction } from '@/helpers/shortcut-actions'
|
||||
import {
|
||||
buildConflicts,
|
||||
clearStoredBinding,
|
||||
resolveAllBindings,
|
||||
storeBinding,
|
||||
} from '@/helpers/shortcut-bindings'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
themeStore.quickScrollEnabled = getQuickScrollEnabled()
|
||||
themeStore.shortcutBindings = resolveAllBindings()
|
||||
for (const action of NAV_ACTIONS) {
|
||||
themeStore[action.enabledField] = getNavShortcutEnabled(action.id)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
scrollTitle: {
|
||||
id: 'app.shortcut-settings.scroll-title',
|
||||
defaultMessage: 'Quick scrolling',
|
||||
},
|
||||
scrollDescription: {
|
||||
id: 'app.shortcut-settings.scroll-description',
|
||||
defaultMessage: 'Move through long pages with the keyboard.',
|
||||
},
|
||||
enable: { id: 'app.shortcut-settings.enable', defaultMessage: 'Enable quick scrolling' },
|
||||
navTitle: {
|
||||
id: 'app.shortcut-settings.nav-title',
|
||||
defaultMessage: 'Navigation shortcuts',
|
||||
},
|
||||
navDescription: {
|
||||
id: 'app.shortcut-settings.nav-description',
|
||||
defaultMessage: 'Jump to a menu item with its shortcut. Each shortcut is off until enabled.',
|
||||
},
|
||||
changeShortcut: {
|
||||
id: 'app.shortcut-settings.change-shortcut',
|
||||
defaultMessage: 'Change the shortcut for {action}',
|
||||
},
|
||||
})
|
||||
|
||||
function actionLabel(action: ShortcutAction) {
|
||||
return formatMessage(action.label)
|
||||
}
|
||||
|
||||
function actionDescription(action: ShortcutAction) {
|
||||
return formatMessage(action.description)
|
||||
}
|
||||
|
||||
function bindingFor(action: ShortcutAction): KeyBinding {
|
||||
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
|
||||
}
|
||||
|
||||
function sameBinding(left: KeyBinding, right: KeyBinding) {
|
||||
return (
|
||||
left.device === right.device &&
|
||||
left.code === right.code &&
|
||||
left.mod === right.mod &&
|
||||
left.shift === right.shift &&
|
||||
left.alt === right.alt
|
||||
)
|
||||
}
|
||||
|
||||
const editing = ref<ShortcutAction | null>(null)
|
||||
const keybindingModal = ref<InstanceType<typeof KeybindingModal>>()
|
||||
|
||||
const editingLabel = computed(() => (editing.value ? actionLabel(editing.value) : undefined))
|
||||
const editingBinding = computed(() => (editing.value ? bindingFor(editing.value) : null))
|
||||
const editingDefault = computed(() => editing.value?.defaultBinding ?? null)
|
||||
const editingConflicts = computed(() =>
|
||||
editing.value
|
||||
? buildConflicts(editing.value.id, (id) => {
|
||||
const action = [...SCROLL_ACTIONS, ...NAV_ACTIONS].find((candidate) => candidate.id === id)
|
||||
return action ? actionLabel(action) : id
|
||||
})
|
||||
: [],
|
||||
)
|
||||
|
||||
function openShortcut(action: ShortcutAction) {
|
||||
editing.value = action
|
||||
keybindingModal.value?.show()
|
||||
}
|
||||
|
||||
function saveShortcut(binding: KeyBinding) {
|
||||
const action = editing.value
|
||||
if (!action) return
|
||||
|
||||
// A combination matching the default is stored as "not changed", so a later
|
||||
// change to the default still reaches this shortcut.
|
||||
if (sameBinding(binding, action.defaultBinding)) clearStoredBinding(action.id)
|
||||
else storeBinding(action.id, binding)
|
||||
|
||||
themeStore.shortcutBindings = { ...themeStore.shortcutBindings, [action.id]: binding }
|
||||
}
|
||||
|
||||
function toggleQuickScroll(value: unknown) {
|
||||
themeStore.quickScrollEnabled = !!value
|
||||
setQuickScrollEnabled(themeStore.quickScrollEnabled)
|
||||
}
|
||||
|
||||
function toggleNavShortcut(action: ShortcutAction, value: unknown) {
|
||||
themeStore[action.enabledField] = !!value
|
||||
setNavShortcutEnabled(action.id, !!value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsSection>
|
||||
<template #header>
|
||||
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.scrollTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.scrollDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow>
|
||||
<template #label>
|
||||
<span id="settings-target-shortcuts-enable" tabindex="-1">
|
||||
{{ formatMessage(messages.enable) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="quick-scroll-enabled"
|
||||
:model-value="themeStore.quickScrollEnabled"
|
||||
@update:model-value="toggleQuickScroll"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow v-for="action in SCROLL_ACTIONS" :key="action.id">
|
||||
<template #label>
|
||||
<span :id="`settings-target-${action.id}`" tabindex="-1">{{ actionLabel(action) }}</span>
|
||||
</template>
|
||||
<template #description>{{ actionDescription(action) }}</template>
|
||||
<template #control>
|
||||
<button
|
||||
type="button"
|
||||
class="keybinding-trigger"
|
||||
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
|
||||
@click="openShortcut(action)"
|
||||
>
|
||||
<KeybindingChips :binding="bindingFor(action)" />
|
||||
</button>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<template #header>
|
||||
<h2
|
||||
id="settings-target-shortcuts-nav"
|
||||
tabindex="-1"
|
||||
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ formatMessage(messages.navTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.navDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow v-for="action in NAV_ACTIONS" :key="action.id">
|
||||
<template #label>{{ actionLabel(action) }}</template>
|
||||
<template #description>{{ actionDescription(action) }}</template>
|
||||
<template #control>
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="keybinding-trigger"
|
||||
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
|
||||
@click="openShortcut(action)"
|
||||
>
|
||||
<KeybindingChips :binding="bindingFor(action)" />
|
||||
</button>
|
||||
<Toggle
|
||||
:id="`nav-shortcut-${action.id}`"
|
||||
:model-value="themeStore[action.enabledField]"
|
||||
@update:model-value="(value) => toggleNavShortcut(action, value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<KeybindingModal
|
||||
ref="keybindingModal"
|
||||
:action-label="editingLabel"
|
||||
:binding="editingBinding"
|
||||
:default-binding="editingDefault"
|
||||
:conflicts="editingConflicts"
|
||||
@save="saveShortcut"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keybinding-trigger {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
padding: 0.25rem 0.375rem;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.keybinding-trigger:hover,
|
||||
.keybinding-trigger:focus-visible {
|
||||
border-color: var(--surface-4);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { exportLauncherLogs, highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const exporting = ref(false)
|
||||
const selectedRange = ref<'last30Minutes' | 'last2Hours' | 'all'>('last2Hours')
|
||||
const selectedLevel = ref<'all' | 'debug' | 'info'>('all')
|
||||
const includeSystemInfo = ref(true)
|
||||
const includeInstanceLogs = ref(false)
|
||||
const includeCrashAnalysis = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.settings.logs.export.modal.title', defaultMessage: 'Export logs' },
|
||||
description: {
|
||||
id: 'app.settings.logs.export.modal.description',
|
||||
defaultMessage:
|
||||
'Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders.',
|
||||
},
|
||||
rangeLabel: { id: 'app.settings.logs.export.modal.range', defaultMessage: 'Time range' },
|
||||
rangeLast30Minutes: {
|
||||
id: 'app.settings.logs.export.modal.range.30-minutes',
|
||||
defaultMessage: 'Last 30 minutes',
|
||||
},
|
||||
rangeLast2Hours: {
|
||||
id: 'app.settings.logs.export.modal.range.2-hours',
|
||||
defaultMessage: 'Last 2 hours',
|
||||
},
|
||||
rangeAll: { id: 'app.settings.logs.export.modal.range.all', defaultMessage: 'All sessions' },
|
||||
levelLabel: { id: 'app.settings.logs.export.modal.level', defaultMessage: 'Log level' },
|
||||
levelAll: { id: 'app.settings.logs.export.modal.level.all', defaultMessage: 'All levels' },
|
||||
levelDebug: {
|
||||
id: 'app.settings.logs.export.modal.level.debug',
|
||||
defaultMessage: 'Debug and above',
|
||||
},
|
||||
levelInfo: { id: 'app.settings.logs.export.modal.level.info', defaultMessage: 'Info and above' },
|
||||
extrasLabel: {
|
||||
id: 'app.settings.logs.export.modal.extras',
|
||||
defaultMessage: 'Additional information',
|
||||
},
|
||||
systemInfo: {
|
||||
id: 'app.settings.logs.export.modal.system-info',
|
||||
defaultMessage: 'System and environment summary',
|
||||
},
|
||||
instanceLogs: {
|
||||
id: 'app.settings.logs.export.modal.instance-logs',
|
||||
defaultMessage: 'Most recent instance log',
|
||||
},
|
||||
crashAnalysis: {
|
||||
id: 'app.settings.logs.export.modal.crash-analysis',
|
||||
defaultMessage: 'Crash analysis (when a crash is detected)',
|
||||
},
|
||||
cancel: { id: 'app.settings.logs.export.modal.cancel', defaultMessage: 'Cancel' },
|
||||
confirm: { id: 'app.settings.logs.export.modal.confirm', defaultMessage: 'Export' },
|
||||
exportedTitle: {
|
||||
id: 'app.settings.logs.export.notification-title',
|
||||
defaultMessage: 'Logs exported',
|
||||
},
|
||||
exportedText: {
|
||||
id: 'app.settings.logs.export.notification-text',
|
||||
defaultMessage: 'The log archive was saved to {path}',
|
||||
},
|
||||
})
|
||||
|
||||
const rangeOptions = computed(() => [
|
||||
{ value: 'last30Minutes', label: formatMessage(messages.rangeLast30Minutes) },
|
||||
{ value: 'last2Hours', label: formatMessage(messages.rangeLast2Hours) },
|
||||
{ value: 'all', label: formatMessage(messages.rangeAll) },
|
||||
])
|
||||
|
||||
const levelOptions = computed(() => [
|
||||
{ value: 'all', label: formatMessage(messages.levelAll) },
|
||||
{ value: 'debug', label: formatMessage(messages.levelDebug) },
|
||||
{ value: 'info', label: formatMessage(messages.levelInfo) },
|
||||
])
|
||||
|
||||
async function exportLogs() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const path = await exportLauncherLogs({
|
||||
range: selectedRange.value,
|
||||
level: selectedLevel.value,
|
||||
includeSystemInfo: includeSystemInfo.value,
|
||||
includeInstanceLogs: includeInstanceLogs.value,
|
||||
includeCrashAnalysis: includeCrashAnalysis.value,
|
||||
})
|
||||
|
||||
if (path) {
|
||||
modal.value?.hide()
|
||||
popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.exportedTitle),
|
||||
text: formatMessage(messages.exportedText, { path }),
|
||||
type: 'success',
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(commonMessages.openInFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightInFolder(path).catch(handleError),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: () => modal.value?.hide(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:closable="true"
|
||||
:close-on-click-outside="true"
|
||||
width="40rem"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0">{{ formatMessage(messages.description) }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.rangeLabel)
|
||||
}}</span>
|
||||
<Combobox
|
||||
id="log-export-range"
|
||||
v-model="selectedRange"
|
||||
:name="formatMessage(messages.rangeLabel)"
|
||||
:options="rangeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.levelLabel)
|
||||
}}</span>
|
||||
<Combobox
|
||||
id="log-export-level"
|
||||
v-model="selectedLevel"
|
||||
:name="formatMessage(messages.levelLabel)"
|
||||
:options="levelOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.extrasLabel)
|
||||
}}</span>
|
||||
<Checkbox v-model="includeSystemInfo" :label="formatMessage(messages.systemInfo)" />
|
||||
<Checkbox v-model="includeInstanceLogs" :label="formatMessage(messages.instanceLogs)" />
|
||||
<Checkbox v-model="includeCrashAnalysis" :label="formatMessage(messages.crashAnalysis)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" :disabled="exporting" @click="modal?.hide()"
|
||||
><XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="exporting" @click="exportLogs"
|
||||
><DownloadIcon />
|
||||
{{ formatMessage(messages.confirm) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
|
||||
import {
|
||||
get_crash_analysis_ai_settings,
|
||||
get_log_share_settings,
|
||||
update_crash_analysis_ai_settings,
|
||||
update_log_share_settings,
|
||||
} from '@/helpers/logs'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
type CrashAISettings = {
|
||||
enabled: boolean
|
||||
provider_id: string
|
||||
model_id: string
|
||||
ai_source: 'logshare' | 'custom'
|
||||
}
|
||||
|
||||
type LogShareSettings = {
|
||||
share_provider: 'logshare' | 'mclogs'
|
||||
ai_source: string
|
||||
auto_upload: boolean
|
||||
multi_file: boolean
|
||||
no_storage: boolean
|
||||
show_progress: boolean
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const settings = ref<LogShareSettings>({
|
||||
share_provider: 'logshare',
|
||||
ai_source: 'logshare',
|
||||
auto_upload: true,
|
||||
multi_file: true,
|
||||
no_storage: false,
|
||||
show_progress: true,
|
||||
})
|
||||
const aiSettings = ref<CrashAISettings>({
|
||||
enabled: false,
|
||||
provider_id: '',
|
||||
model_id: '',
|
||||
ai_source: 'logshare',
|
||||
})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.log-share.settings.title', defaultMessage: 'Log sharing & AI analysis' },
|
||||
description: {
|
||||
id: 'app.log-share.settings.description',
|
||||
defaultMessage:
|
||||
'Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback.',
|
||||
},
|
||||
shareProvider: {
|
||||
id: 'app.log-share.settings.share-provider',
|
||||
defaultMessage: 'Log sharing service',
|
||||
},
|
||||
shareProviderDescription: {
|
||||
id: 'app.log-share.settings.share-provider-description',
|
||||
defaultMessage: 'Where the diagnostic link is uploaded when you share it.',
|
||||
},
|
||||
aiSource: { id: 'app.log-share.settings.ai-source', defaultMessage: 'AI analysis source' },
|
||||
aiSourceLogShare: {
|
||||
id: 'app.log-share.settings.ai-source-logshare',
|
||||
defaultMessage: 'LogShare AI',
|
||||
},
|
||||
aiSourceCustom: {
|
||||
id: 'app.log-share.settings.ai-source-custom',
|
||||
defaultMessage: 'Custom AI provider',
|
||||
},
|
||||
aiSourceDescription: {
|
||||
id: 'app.log-share.settings.ai-source-description',
|
||||
defaultMessage:
|
||||
'Both sources send a sanitized and shortened crash context. AI output may be inaccurate.',
|
||||
},
|
||||
enabled: {
|
||||
id: 'app.crash-analysis.ai.settings.enabled',
|
||||
defaultMessage: 'Enable AI explanations',
|
||||
},
|
||||
enabledDescription: {
|
||||
id: 'app.crash-analysis.ai.settings.enabled-description',
|
||||
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
|
||||
},
|
||||
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
|
||||
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
|
||||
noProviders: {
|
||||
id: 'app.crash-analysis.ai.settings.no-providers',
|
||||
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
|
||||
},
|
||||
autoUpload: {
|
||||
id: 'app.log-share.settings.auto-upload',
|
||||
defaultMessage: 'Upload to get a structured summary automatically',
|
||||
},
|
||||
autoUploadDescription: {
|
||||
id: 'app.log-share.settings.auto-upload-description',
|
||||
defaultMessage: 'Only effective in LogShare AI mode.',
|
||||
},
|
||||
multiFile: { id: 'app.log-share.settings.multi-file', defaultMessage: 'Multi-file upload' },
|
||||
multiFileDescription: {
|
||||
id: 'app.log-share.settings.multi-file-description',
|
||||
defaultMessage: 'Upload latest.log, crash reports, hs_err and launcher logs together.',
|
||||
},
|
||||
noStorage: {
|
||||
id: 'app.log-share.settings.no-storage',
|
||||
defaultMessage: 'Analyze without storing logs',
|
||||
},
|
||||
noStorageDescription: {
|
||||
id: 'app.log-share.settings.no-storage-description',
|
||||
defaultMessage:
|
||||
'Submit content directly for analysis and skip the stored upload. Disables the automatic summary.',
|
||||
},
|
||||
showProgress: {
|
||||
id: 'app.log-share.settings.show-progress',
|
||||
defaultMessage: 'Show AI analysis progress',
|
||||
},
|
||||
showProgressDescription: {
|
||||
id: 'app.log-share.settings.show-progress-description',
|
||||
defaultMessage: 'Display thinking, tool and limit status while LogShare AI is working.',
|
||||
},
|
||||
})
|
||||
|
||||
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
|
||||
const aiState = computed(() => sharedAIState.value ?? emptyState)
|
||||
const providers = computed(() =>
|
||||
aiState.value.providers.filter(
|
||||
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
|
||||
),
|
||||
)
|
||||
const providerOptions = computed(() =>
|
||||
providers.value.map((provider) => ({
|
||||
value: provider.provider_id,
|
||||
label: provider.custom_name || provider.provider_id,
|
||||
})),
|
||||
)
|
||||
const modelOptions = computed(
|
||||
() =>
|
||||
providers.value
|
||||
.find((provider) => provider.provider_id === aiSettings.value.provider_id)
|
||||
?.models.filter((model) => model.enabled)
|
||||
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
|
||||
)
|
||||
|
||||
const isCustom = computed(() => aiSettings.value.ai_source === 'custom')
|
||||
const noStorageEnabled = computed(() => aiSettings.value.ai_source === 'logshare')
|
||||
|
||||
const shareProviderOptions = [
|
||||
{ value: 'logshare', label: 'LogShare' },
|
||||
{ value: 'mclogs', label: 'mclo.gs' },
|
||||
]
|
||||
const aiSourceOptions = [
|
||||
{ value: 'logshare', label: formatMessage(messages.aiSourceLogShare) },
|
||||
{ value: 'custom', label: formatMessage(messages.aiSourceCustom) },
|
||||
]
|
||||
|
||||
async function saveShare(next: LogShareSettings): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await update_log_share_settings(next)
|
||||
settings.value = next
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAI(next: CrashAISettings): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await update_crash_analysis_ai_settings(next)
|
||||
aiSettings.value = next
|
||||
settings.value = { ...settings.value, ai_source: next.ai_source }
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateShare(patch: Partial<LogShareSettings>): void {
|
||||
void saveShare({ ...settings.value, ...patch })
|
||||
}
|
||||
|
||||
function selectShareProvider(value: unknown): void {
|
||||
const provider = value === 'mclogs' ? 'mclogs' : 'logshare'
|
||||
updateShare({ share_provider: provider, ai_source: aiSettings.value.ai_source })
|
||||
}
|
||||
|
||||
function selectAiSource(value: unknown): void {
|
||||
const source = value === 'custom' ? 'custom' : 'logshare'
|
||||
void saveAI({ ...aiSettings.value, ai_source: source })
|
||||
}
|
||||
|
||||
function updateEnabled(enabled: boolean): void {
|
||||
void saveAI({ ...aiSettings.value, enabled })
|
||||
}
|
||||
|
||||
function updateProvider(providerId: string): void {
|
||||
const provider = providers.value.find((item) => item.provider_id === providerId)
|
||||
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
|
||||
void saveAI({ ...aiSettings.value, provider_id: providerId, model_id: modelId })
|
||||
}
|
||||
|
||||
function updateModel(modelId: string): void {
|
||||
void saveAI({ ...aiSettings.value, model_id: modelId })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [nextShare, nextAI] = await Promise.all([
|
||||
get_log_share_settings(),
|
||||
get_crash_analysis_ai_settings(),
|
||||
getAIState(),
|
||||
])
|
||||
settings.value = { ...nextShare, ai_source: nextAI.ai_source }
|
||||
aiSettings.value = nextAI
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSection
|
||||
v-if="!loading"
|
||||
id="settings-target-log-share"
|
||||
:title="formatMessage(messages.title)"
|
||||
:description="formatMessage(messages.description)"
|
||||
>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.shareProvider) }}</template>
|
||||
<template #description>{{ formatMessage(messages.shareProviderDescription) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="log-share-provider"
|
||||
:model-value="settings.share_provider"
|
||||
:options="shareProviderOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="selectShareProvider($event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.aiSource) }}</template>
|
||||
<template #description>{{ formatMessage(messages.aiSourceDescription) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="log-share-ai-source"
|
||||
:model-value="aiSettings.ai_source"
|
||||
:options="aiSourceOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="selectAiSource($event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-if="isCustom">
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.enabled) }}</template>
|
||||
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="crash-analysis-ai-enabled"
|
||||
:model-value="aiSettings.enabled"
|
||||
:disabled="saving || !aiState.settings.enabled || !providers.length"
|
||||
@update:model-value="updateEnabled(!!$event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-if="providers.length">
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.provider) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="crash-analysis-ai-provider"
|
||||
:model-value="aiSettings.provider_id"
|
||||
:options="providerOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateProvider(String($event))"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.model) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="crash-analysis-ai-model"
|
||||
:model-value="aiSettings.model_id"
|
||||
:options="modelOptions"
|
||||
:disabled="saving || !aiSettings.provider_id"
|
||||
@update:model-value="updateModel(String($event))"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
<p v-else class="m-0 p-4 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.noProviders) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.autoUpload) }}</template>
|
||||
<template #description>{{ formatMessage(messages.autoUploadDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-auto-upload"
|
||||
:model-value="settings.auto_upload"
|
||||
:disabled="saving || !noStorageEnabled || settings.no_storage"
|
||||
@update:model-value="updateShare({ auto_upload: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.multiFile) }}</template>
|
||||
<template #description>{{ formatMessage(messages.multiFileDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-multi-file"
|
||||
:model-value="settings.multi_file"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateShare({ multi_file: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.noStorage) }}</template>
|
||||
<template #description>{{ formatMessage(messages.noStorageDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-no-storage"
|
||||
:model-value="settings.no_storage"
|
||||
:disabled="saving || !noStorageEnabled"
|
||||
@update:model-value="updateShare({ no_storage: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.showProgress) }}</template>
|
||||
<template #description>{{ formatMessage(messages.showProgressDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-show-progress"
|
||||
:model-value="settings.show_progress"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateShare({ show_progress: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { showLauncherLogsFolder } from '@/helpers/utils'
|
||||
|
||||
import LogExportModal from './LogExportModal.vue'
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const exportModal = ref<InstanceType<typeof LogExportModal>>()
|
||||
|
||||
const messages = defineMessages({
|
||||
exportTitle: { id: 'app.settings.logs.export.title', defaultMessage: 'Export logs' },
|
||||
exportDescription: {
|
||||
id: 'app.settings.logs.export.description',
|
||||
defaultMessage:
|
||||
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
|
||||
},
|
||||
exportButton: { id: 'app.settings.logs.export.button', defaultMessage: 'Export logs…' },
|
||||
openFolder: { id: 'app.settings.logs.open-folder', defaultMessage: 'Open logs folder' },
|
||||
})
|
||||
|
||||
async function openLogsFolder() {
|
||||
try {
|
||||
await showLauncherLogsFolder()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsSection
|
||||
:title="formatMessage(messages.exportTitle)"
|
||||
:description="formatMessage(messages.exportDescription)"
|
||||
title-id="settings-target-logs-export"
|
||||
>
|
||||
<SettingsRow stacked>
|
||||
<template #label>
|
||||
<span>{{ formatMessage(messages.exportTitle) }}</span>
|
||||
</template>
|
||||
<template #description>{{ formatMessage(messages.exportDescription) }}</template>
|
||||
<template #control>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="colored" color="brand" @click="exportModal?.show()"
|
||||
><UploadIcon />
|
||||
{{ formatMessage(messages.exportButton) }}
|
||||
</Button>
|
||||
<Button @click="openLogsFolder"
|
||||
><FolderOpenIcon />
|
||||
{{ formatMessage(messages.openFolder) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<LogExportModal ref="exportModal" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -253,6 +253,15 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
|
||||
},
|
||||
ignoreSslErrors: {
|
||||
id: 'app.settings.resources.ignore-ssl-errors',
|
||||
defaultMessage: 'Ignore SSL certificate errors',
|
||||
},
|
||||
ignoreSslErrorsDescription: {
|
||||
id: 'app.settings.resources.ignore-ssl-errors-description',
|
||||
defaultMessage:
|
||||
'Skip TLS certificate validation for launcher network requests. Only enable this if you trust your network and understand the security risk.',
|
||||
},
|
||||
mojangAuthService: {
|
||||
id: 'app.settings.resources.mojang-auth-service',
|
||||
defaultMessage: 'Mojang authentication service',
|
||||
@@ -923,6 +932,15 @@ function validateMinecraftDirectory(value) {
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.ignoreSslErrors) }}</template>
|
||||
<template #description>
|
||||
{{ formatMessage(messages.ignoreSslErrorsDescription) }}
|
||||
</template>
|
||||
<template #control>
|
||||
<Toggle id="ignore-ssl-errors" v-model="settings.ignore_ssl_errors" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection v-if="props.scope === 'content-downloads'">
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { delete_shared_log, list_shared_logs } from '@/helpers/logs'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
type SharedLog = {
|
||||
id: string
|
||||
url: string
|
||||
raw: string
|
||||
token: string
|
||||
provider: string
|
||||
instance_id: string | null
|
||||
instance_name: string | null
|
||||
truncated: boolean
|
||||
created_at: number
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const logs = ref<SharedLog[]>([])
|
||||
const loading = ref(true)
|
||||
const deletingId = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.log-share.shared-logs.title', defaultMessage: 'Shared logs' },
|
||||
description: {
|
||||
id: 'app.log-share.shared-logs.description',
|
||||
defaultMessage:
|
||||
'Every diagnostic you have shared is listed here. Delete an entry to remove it from your history and request its removal from the sharing service.',
|
||||
},
|
||||
empty: { id: 'app.log-share.shared-logs.empty', defaultMessage: 'No shared logs yet.' },
|
||||
delete: { id: 'app.log-share.shared-logs.delete', defaultMessage: 'Delete' },
|
||||
deleting: { id: 'app.log-share.shared-logs.deleting', defaultMessage: 'Deleting…' },
|
||||
deleted: {
|
||||
id: 'app.log-share.shared-logs.deleted',
|
||||
defaultMessage: 'Shared log removed.',
|
||||
},
|
||||
deleteFailed: {
|
||||
id: 'app.log-share.shared-logs.delete-failed',
|
||||
defaultMessage: 'Failed to delete the shared log.',
|
||||
},
|
||||
truncated: { id: 'app.log-share.shared-logs.truncated', defaultMessage: 'Truncated' },
|
||||
})
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
logs.value = await list_shared_logs()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(log: SharedLog): Promise<void> {
|
||||
if (deletingId.value) return
|
||||
deletingId.value = log.id
|
||||
try {
|
||||
await delete_shared_log(log.id, log.token)
|
||||
logs.value = logs.value.filter((item) => item.id !== log.id)
|
||||
addNotification({ title: formatMessage(messages.deleted), type: 'success' })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
addNotification({ title: formatMessage(messages.deleteFailed), type: 'error' })
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSection
|
||||
id="settings-target-shared-logs"
|
||||
:title="formatMessage(messages.title)"
|
||||
:description="formatMessage(messages.description)"
|
||||
>
|
||||
<SettingsRow stacked>
|
||||
<template #control>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<p v-if="loading" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">…</p>
|
||||
<p v-else-if="!logs.length" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex max-h-72 list-none flex-col gap-1 overflow-y-auto p-2">
|
||||
<li
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="flex items-center gap-3 rounded-lg bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<a
|
||||
:href="log.url || `https://logshare.cn/${log.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 flex-1 truncate text-[var(--color-text-default)] underline"
|
||||
>
|
||||
{{ log.url || log.id }}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
||||
{{ log.provider }}
|
||||
<template v-if="log.truncated">
|
||||
· {{ formatMessage(messages.truncated) }}
|
||||
</template>
|
||||
<template v-if="log.instance_name"> · {{ log.instance_name }} </template>
|
||||
· {{ formatDate(log.created_at) }}
|
||||
</span>
|
||||
<Button type="outlined" :disabled="deletingId === log.id" @click="remove(log)"
|
||||
>{{
|
||||
deletingId === log.id
|
||||
? formatMessage(messages.deleting)
|
||||
: formatMessage(messages.delete)
|
||||
}}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import BackupRepositorySettings from './BackupRepositorySettings.vue'
|
||||
import ResourceManagementSettings from './ResourceManagementSettings.vue'
|
||||
import StorageSettings from './StorageSettings.vue'
|
||||
</script>
|
||||
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ResourceManagementSettings scope="storage-backups" />
|
||||
<BackupRepositorySettings />
|
||||
<StorageSettings />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CheckCircleButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
sources: {
|
||||
id: string
|
||||
name: string
|
||||
icon_path?: string | null
|
||||
eligible: boolean
|
||||
}[]
|
||||
loading?: boolean
|
||||
pending?: boolean
|
||||
error?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
close: []
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const selectedInstanceId = defineModel<string>({ required: true })
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const search = ref('')
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
ineligible: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.ineligible',
|
||||
defaultMessage: 'You cannot choose this instance as a sync source',
|
||||
},
|
||||
search: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
|
||||
defaultMessage: 'Search instance',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
sync: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.sync',
|
||||
defaultMessage: 'Sync',
|
||||
},
|
||||
loadError: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.load-error',
|
||||
defaultMessage: 'Could not load sync sources. Please try again.',
|
||||
},
|
||||
retry: {
|
||||
id: 'app.settings.synced-options.choose-sync-source.retry',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
})
|
||||
|
||||
const filteredSources = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
return props.sources.filter((source) => source.name.toLocaleLowerCase().includes(query))
|
||||
})
|
||||
const canConfirm = computed(
|
||||
() =>
|
||||
!props.loading &&
|
||||
!props.pending &&
|
||||
!props.error &&
|
||||
props.sources.some((source) => source.id === selectedInstanceId.value && source.eligible),
|
||||
)
|
||||
|
||||
function show() {
|
||||
search.value = ''
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
return modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="title"
|
||||
no-padding
|
||||
actions-divider
|
||||
max-width="560px"
|
||||
width="560px"
|
||||
:disable-close="pending"
|
||||
:on-after-hide="() => emit('close')"
|
||||
>
|
||||
<p
|
||||
class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-[var(--color-text-default)]"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
:aria-label="formatMessage(messages.search)"
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center" aria-busy="true">
|
||||
<SpinnerIcon class="size-5 animate-spin text-[var(--color-text-tertiary)]" />
|
||||
</div>
|
||||
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3">
|
||||
<p role="alert" class="m-0 text-center text-[var(--color-text-default)]">
|
||||
{{ formatMessage(messages.loadError) }}
|
||||
</p>
|
||||
<Button @click="emit('retry')">{{ formatMessage(messages.retry) }}</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredSources.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else role="radiogroup" :aria-label="title" class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="source in filteredSources"
|
||||
:key="source.id"
|
||||
v-tooltip="!source.eligible ? formatMessage(messages.ineligible) : undefined"
|
||||
>
|
||||
<CheckCircleButton
|
||||
:checked="selectedInstanceId === source.id"
|
||||
:disabled="pending || !source.eligible"
|
||||
class="min-h-10"
|
||||
@click="selectedInstanceId = source.id"
|
||||
>
|
||||
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(source.icon_path)"
|
||||
:alt="source.name"
|
||||
:tint-by="source.id"
|
||||
size="1.25rem"
|
||||
no-shadow
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ source.name }}</span>
|
||||
</CheckCircleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 p-2">
|
||||
<Button type="outlined" :disabled="pending" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!canConfirm"
|
||||
:loading="pending"
|
||||
@click="canConfirm && emit('confirm')"
|
||||
>
|
||||
<RefreshCwIcon :class="{ 'animate-spin': pending }" aria-hidden="true" />
|
||||
{{ formatMessage(messages.sync) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
remove_synced_pack,
|
||||
set_synced_pack_enabled,
|
||||
syncedPackKeys,
|
||||
syncedPackQueryOptions,
|
||||
type SyncedPackType,
|
||||
} from '@/helpers/synced-packs'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const type = ref<SyncedPackType>('resourcepack')
|
||||
const queryClient = useQueryClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
resourcePacks: {
|
||||
id: 'app.settings.synced-packs.resource-packs',
|
||||
defaultMessage: 'Resource packs',
|
||||
},
|
||||
dataPacks: { id: 'app.settings.synced-packs.data-packs', defaultMessage: 'Data packs' },
|
||||
enable: { id: 'app.settings.synced-packs.enable', defaultMessage: 'Enable' },
|
||||
disable: { id: 'app.settings.synced-packs.disable', defaultMessage: 'Disable' },
|
||||
remove: { id: 'app.settings.synced-packs.remove', defaultMessage: 'Remove' },
|
||||
empty: { id: 'app.settings.synced-packs.empty', defaultMessage: 'No synced packs' },
|
||||
})
|
||||
const query = useQuery(computed(() => syncedPackQueryOptions(type.value)))
|
||||
const packs = computed(() => query.value.data.value ?? [])
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (request: { id: string; enabled?: boolean; remove?: boolean }) => {
|
||||
if (request.remove) await remove_synced_pack(request.id)
|
||||
else await set_synced_pack_enabled(request.id, request.enabled ?? true)
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||
})
|
||||
|
||||
async function show(nextType: SyncedPackType) {
|
||||
type.value = nextType
|
||||
modal.value?.show()
|
||||
await queryClient.invalidateQueries({ queryKey: syncedPackKeys.list(nextType) })
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(type === 'resourcepack' ? messages.resourcePacks : messages.dataPacks)"
|
||||
max-width="42rem"
|
||||
>
|
||||
<div v-if="packs.length" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="pack in packs"
|
||||
:key="pack.id"
|
||||
class="flex items-center gap-3 border-b border-solid border-surface-4 py-2"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ pack.file_name }}</span>
|
||||
<Button
|
||||
:disabled="mutation.isPending.value"
|
||||
@click="mutation.mutate({ id: pack.id, enabled: !pack.enabled })"
|
||||
>{{ formatMessage(pack.enabled ? messages.disable : messages.enable) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="mutation.isPending.value"
|
||||
@click="mutation.mutate({ id: pack.id, remove: true })"
|
||||
>{{ formatMessage(messages.remove) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.empty) }}</p>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
|
||||
import { set_command_history } from '@/helpers/instance'
|
||||
import { commandHistoryQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const commandHistory = ref('')
|
||||
const historyQuery = useQuery({ ...commandHistoryQueryOptions(), enabled: false })
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: set_command_history,
|
||||
onSuccess: (history) => {
|
||||
queryClient.setQueryData(syncedOptionsKeys.commandHistory, history)
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
commandHistoryEditorTitle: {
|
||||
id: 'app.settings.synced-options.command-history.editor-title',
|
||||
defaultMessage: 'Edit command history',
|
||||
},
|
||||
})
|
||||
|
||||
async function show() {
|
||||
const result = await historyQuery.refetch()
|
||||
if (result.isError) {
|
||||
handleError(result.error)
|
||||
return
|
||||
}
|
||||
if (!result.isSuccess) return
|
||||
commandHistory.value = result.data
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function saveCommandHistory() {
|
||||
if (saveMutation.isPending.value) return
|
||||
try {
|
||||
await saveMutation.mutateAsync(commandHistory.value)
|
||||
await nextTick()
|
||||
modal.value?.hide()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.commandHistoryEditorTitle)"
|
||||
:disable-close="saveMutation.isPending.value"
|
||||
no-padding
|
||||
actions-divider
|
||||
max-width="700px"
|
||||
width="700px"
|
||||
>
|
||||
<StudioEditor
|
||||
v-model:content="commandHistory"
|
||||
file-path="command_history.txt"
|
||||
language="plaintext"
|
||||
class="h-[420px] text-sm"
|
||||
/>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="modal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" :loading="saveMutation.isPending.value" @click="saveCommandHistory">
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onLabel: string
|
||||
offLabel: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-tooltip="modelValue === undefined ? placeholder : undefined"
|
||||
role="group"
|
||||
:aria-label="label"
|
||||
class="inline-flex h-9 items-center gap-0.5 rounded-xl border border-solid border-surface-5 p-px shadow-[0_1px_3px_rgba(0,0,0,0.15)]"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
>
|
||||
<Button
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:interaction="modelValue === true ? 'none' : 'surface'"
|
||||
class="boolean-control-on !rounded-[10px] !px-3 !font-medium disabled:!opacity-100"
|
||||
:class="modelValue === true ? '!text-green' : '!text-[var(--color-text-primary)]'"
|
||||
:aria-pressed="modelValue === true"
|
||||
:disabled="disabled"
|
||||
@click="emit('update:model-value', true)"
|
||||
>
|
||||
{{ onLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:interaction="modelValue === false ? 'none' : 'surface'"
|
||||
class="boolean-control-off !rounded-[10px] !px-3 !font-medium !text-[var(--color-text-primary)] disabled:!opacity-100"
|
||||
:aria-pressed="modelValue === false"
|
||||
:disabled="disabled"
|
||||
@click="emit('update:model-value', false)"
|
||||
>
|
||||
{{ offLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.boolean-control-on[aria-pressed='true'] {
|
||||
background-color: color-mix(in srgb, var(--color-green) 30%, var(--surface-3));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-green) 60%, transparent);
|
||||
}
|
||||
|
||||
.boolean-control-off[aria-pressed='true'] {
|
||||
background-color: var(--surface-4);
|
||||
box-shadow: inset 0 0 0 1px var(--surface-5);
|
||||
}
|
||||
</style>
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import type {
|
||||
EditableGameSetting,
|
||||
GameOptionCanonicalValue,
|
||||
GameSettingChange,
|
||||
GameSettingsEditorState,
|
||||
} from '@/helpers/game-options'
|
||||
|
||||
function clonePlainValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return toRaw(value).map(clonePlainValue)
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(toRaw(value)).map(([key, nestedValue]) => [key, clonePlainValue(nestedValue)]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function cloneGameSettingsState(state: GameSettingsEditorState): GameSettingsEditorState {
|
||||
return clonePlainValue(state) as GameSettingsEditorState
|
||||
}
|
||||
|
||||
export function canonicalValuesEqual(
|
||||
left: GameOptionCanonicalValue | null | undefined,
|
||||
right: GameOptionCanonicalValue | null | undefined,
|
||||
): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
||||
}
|
||||
|
||||
export function gameSettingChanges(
|
||||
base: GameSettingsEditorState | null,
|
||||
draft: GameSettingsEditorState | null,
|
||||
forcedValueOptionIds: ReadonlySet<string> = new Set(),
|
||||
): GameSettingChange[] {
|
||||
if (!base || !draft) return []
|
||||
|
||||
const baseSettings = new Map(base.settings.map((setting) => [setting.option_id, setting]))
|
||||
return draft.settings.flatMap((setting) => {
|
||||
const previous = baseSettings.get(setting.option_id)
|
||||
if (!previous) return []
|
||||
|
||||
const syncChanged = previous.sync_enabled !== setting.sync_enabled
|
||||
const valueChanged = !canonicalValuesEqual(previous.canonical_value, setting.canonical_value)
|
||||
const promoteLocalValue =
|
||||
forcedValueOptionIds.has(setting.option_id) &&
|
||||
previous.value_state !== 'canonical' &&
|
||||
setting.canonical_value !== null &&
|
||||
setting.canonical_value !== undefined
|
||||
if (!syncChanged && !valueChanged && !promoteLocalValue) return []
|
||||
|
||||
return [
|
||||
{
|
||||
option_id: setting.option_id,
|
||||
base_option_revision: previous.option_revision,
|
||||
...(syncChanged ? { sync_enabled: setting.sync_enabled } : {}),
|
||||
...(valueChanged || promoteLocalValue
|
||||
? { canonical_value: setting.canonical_value ?? null }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function gameSettingNumberScale(setting: EditableGameSetting): number {
|
||||
if (setting.editor.unit !== 'percent') return 1
|
||||
return setting.option_id === 'sensitivity' ? 200 : 100
|
||||
}
|
||||
|
||||
export function canonicalValueText(setting: EditableGameSetting): string {
|
||||
const value = setting.canonical_value
|
||||
if (!value) return ''
|
||||
|
||||
switch (value.type) {
|
||||
case 'bool':
|
||||
return value.value ? 'true' : 'false'
|
||||
case 'integer':
|
||||
case 'decimal':
|
||||
return setting.editor.unit === 'percent'
|
||||
? String(Number((Number(value.value) * gameSettingNumberScale(setting)).toFixed(8)))
|
||||
: String(value.value)
|
||||
case 'string_list':
|
||||
return value.value.join(', ')
|
||||
default:
|
||||
return value.value
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalBooleanValue(setting: EditableGameSetting): boolean | undefined {
|
||||
return setting.canonical_value?.type === 'bool' ? setting.canonical_value.value : undefined
|
||||
}
|
||||
|
||||
export function isKeybindSetting(setting: EditableGameSetting): boolean {
|
||||
return (
|
||||
setting.editor.type === 'key_binding' ||
|
||||
(setting.editor.type === 'external_raw' && !!setting.raw_key?.startsWith('key_'))
|
||||
)
|
||||
}
|
||||
|
||||
export function canonicalValueFromInput(
|
||||
setting: EditableGameSetting,
|
||||
value: string | number | boolean | undefined,
|
||||
): GameOptionCanonicalValue | null {
|
||||
if (value === undefined || value === '') return null
|
||||
|
||||
switch (setting.editor.type) {
|
||||
case 'bool':
|
||||
return { type: 'bool', value: Boolean(value) }
|
||||
case 'integer': {
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) ? { type: 'integer', value: parsed } : null
|
||||
}
|
||||
case 'decimal': {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return {
|
||||
type: 'decimal',
|
||||
value:
|
||||
setting.editor.unit === 'percent'
|
||||
? String(Number((parsed / gameSettingNumberScale(setting)).toFixed(8)))
|
||||
: String(value),
|
||||
}
|
||||
}
|
||||
case 'enum':
|
||||
return { type: 'enum', value: String(value) }
|
||||
case 'string_list':
|
||||
return {
|
||||
type: 'string_list',
|
||||
value: String(value)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
case 'key_binding':
|
||||
return { type: 'key_binding', value: String(value) }
|
||||
case 'external_raw':
|
||||
return { type: 'external_raw', value: String(value) }
|
||||
default:
|
||||
return { type: 'text', value: String(value) }
|
||||
}
|
||||
}
|
||||
|
||||
export function settingSearchText(
|
||||
setting: EditableGameSetting,
|
||||
label: string,
|
||||
description: string,
|
||||
): string {
|
||||
return [label, description, setting.option_id, setting.raw_key]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
export function settingCanBeEnabled(setting: EditableGameSetting): boolean {
|
||||
return (
|
||||
!setting.controlled &&
|
||||
!setting.validation_error &&
|
||||
!['mixed', 'unset', 'invalid'].includes(setting.value_state) &&
|
||||
(setting.compatibility.total_participating === 0 || setting.compatibility.will_receive > 0)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EyeIcon,
|
||||
LanguagesIcon,
|
||||
LinkIcon,
|
||||
MessageIcon,
|
||||
MonitorIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
ShirtIcon,
|
||||
TagCategoryAudioIcon,
|
||||
TagCategoryGamepad2Icon,
|
||||
UnlinkIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FloatingActionBar,
|
||||
StyledInput,
|
||||
TabbedModal,
|
||||
type TabbedModalTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { type RouteLocationRaw, useRouter } from 'vue-router'
|
||||
|
||||
import type { EditableGameSetting, GameSettingCategory } from '@/helpers/game-options'
|
||||
import { injectInstanceSettings } from '@/pages/instance/components/settings-modal/instance-settings-context'
|
||||
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
|
||||
|
||||
import {
|
||||
canonicalValueText,
|
||||
isKeybindSetting,
|
||||
settingCanBeEnabled,
|
||||
settingSearchText,
|
||||
} from './editors'
|
||||
import { minecraftKeybindConflictKey } from './keybinds'
|
||||
import {
|
||||
formatGameSettingDescription,
|
||||
formatGameSettingLabel,
|
||||
gameSettingCategoryMessage,
|
||||
} from './messages'
|
||||
import GameSettingRow from './row.vue'
|
||||
import { useGameSettingsEditor } from './use-editor'
|
||||
import { useGameSettingLabels } from './use-labels'
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const appSettingsModal = inject(appSettingsModalContextKey, null)
|
||||
const instanceSettings = injectInstanceSettings(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.settings.synced-options.game-settings.title',
|
||||
defaultMessage: 'Game settings',
|
||||
},
|
||||
syncTitle: {
|
||||
id: 'app.settings.synced-options.game-settings.sync-title',
|
||||
defaultMessage: 'Sync game settings',
|
||||
},
|
||||
syncedOptionsDescription: {
|
||||
id: 'app.settings.synced-options.game-settings.sync-description',
|
||||
defaultMessage:
|
||||
'Synced options are applied to every instance and override the values included with a modpack.',
|
||||
},
|
||||
search: {
|
||||
id: 'app.settings.synced-options.game-settings.search',
|
||||
defaultMessage: 'Search settings...',
|
||||
},
|
||||
enableAll: {
|
||||
id: 'app.settings.synced-options.game-settings.enable-all',
|
||||
defaultMessage: 'Sync all',
|
||||
},
|
||||
disableAll: {
|
||||
id: 'app.settings.synced-options.game-settings.disable-all',
|
||||
defaultMessage: 'Unsync all',
|
||||
},
|
||||
customEmpty: {
|
||||
id: 'app.settings.synced-options.game-settings.custom-empty',
|
||||
defaultMessage:
|
||||
'Settings added by mods will appear here after Modrinth finds them in one of your instances.',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.settings.synced-options.game-settings.empty',
|
||||
defaultMessage: 'No settings match your search or filter.',
|
||||
},
|
||||
loading: {
|
||||
id: 'app.settings.synced-options.game-settings.loading',
|
||||
defaultMessage: 'Loading settings...',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'app.settings.synced-options.game-settings.load-failed',
|
||||
defaultMessage: 'We couldn’t load your game settings.',
|
||||
},
|
||||
retry: {
|
||||
id: 'app.settings.synced-options.game-settings.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
saveSettings: {
|
||||
id: 'app.settings.synced-options.game-settings.save',
|
||||
defaultMessage: 'Save settings',
|
||||
},
|
||||
unsavedChanges: {
|
||||
id: 'app.settings.synced-options.game-settings.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes.',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
const activeCategoryId = ref('')
|
||||
const search = ref('')
|
||||
const opened = ref(false)
|
||||
let allowClose = false
|
||||
|
||||
const {
|
||||
draftState,
|
||||
isLocalEditor,
|
||||
isDirty,
|
||||
hasBlockingDraft,
|
||||
loading,
|
||||
loadError,
|
||||
saving,
|
||||
load,
|
||||
reset: resetEditor,
|
||||
cancelChanges,
|
||||
setSyncEnabled,
|
||||
setCanonicalValue,
|
||||
save,
|
||||
saveAndWait,
|
||||
} = useGameSettingsEditor(
|
||||
() => props.instanceId,
|
||||
() => emit('saved'),
|
||||
)
|
||||
|
||||
const categoryIcons: Record<string, Component> = {
|
||||
skin_customization: ShirtIcon,
|
||||
video: MonitorIcon,
|
||||
video_settings: MonitorIcon,
|
||||
language: LanguagesIcon,
|
||||
music_and_sound: TagCategoryAudioIcon,
|
||||
controls: TagCategoryGamepad2Icon,
|
||||
chat: MessageIcon,
|
||||
chat_settings: MessageIcon,
|
||||
accessibility: EyeIcon,
|
||||
custom: WrenchIcon,
|
||||
custom_settings: WrenchIcon,
|
||||
}
|
||||
|
||||
const localeLabels = useGameSettingLabels(
|
||||
opened,
|
||||
() => props.instanceId,
|
||||
() => draftState.value?.settings ?? [],
|
||||
)
|
||||
|
||||
function settingLabel(setting: EditableGameSetting) {
|
||||
if (setting.kind === 'external') {
|
||||
return (
|
||||
localeLabels.value[setting.option_id]?.label ?? formatGameSettingLabel(formatMessage, setting)
|
||||
)
|
||||
}
|
||||
return formatGameSettingLabel(formatMessage, setting)
|
||||
}
|
||||
|
||||
const categories = computed<GameSettingCategory[]>(() => {
|
||||
if (!draftState.value) return []
|
||||
|
||||
const settings = draftState.value.settings
|
||||
const visible = draftState.value.categories.filter(
|
||||
(category) =>
|
||||
category.is_custom || settings.some((setting) => setting.category_id === category.id),
|
||||
)
|
||||
if (!visible.some((category) => category.is_custom || category.id === 'custom_settings')) {
|
||||
visible.push({
|
||||
id: 'custom_settings',
|
||||
is_custom: true,
|
||||
})
|
||||
}
|
||||
return visible
|
||||
})
|
||||
|
||||
const categoryTabs = computed<TabbedModalTab[]>(() =>
|
||||
categories.value.map((category) => ({
|
||||
name: gameSettingCategoryMessage(category),
|
||||
icon: categoryIcon(category),
|
||||
})),
|
||||
)
|
||||
|
||||
const activeCategory = computed(() =>
|
||||
categories.value.find((category) => category.id === activeCategoryId.value),
|
||||
)
|
||||
|
||||
const categorySettings = computed(() => {
|
||||
if (!draftState.value) return []
|
||||
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
return draftState.value.settings.filter((setting) => {
|
||||
if (!query && setting.category_id !== activeCategoryId.value) return false
|
||||
if (
|
||||
query &&
|
||||
!settingSearchText(
|
||||
setting,
|
||||
settingLabel(setting),
|
||||
setting.kind === 'external'
|
||||
? (localeLabels.value[setting.option_id]?.source?.project?.title ??
|
||||
localeLabels.value[setting.option_id]?.source?.file_name ??
|
||||
'')
|
||||
: formatGameSettingDescription(formatMessage, setting),
|
||||
).includes(query)
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const keybindConflicts = computed(() => {
|
||||
if (!draftState.value) return new Map<string, string[]>()
|
||||
|
||||
const bindings = new Map<string, EditableGameSetting[]>()
|
||||
for (const setting of draftState.value.settings) {
|
||||
if (!isKeybindSetting(setting)) continue
|
||||
const key = minecraftKeybindConflictKey(setting.option_id, canonicalValueText(setting))
|
||||
if (!key) continue
|
||||
bindings.set(key, [...(bindings.get(key) ?? []), setting])
|
||||
}
|
||||
|
||||
const conflicts = new Map<string, string[]>()
|
||||
for (const settings of bindings.values()) {
|
||||
if (settings.length < 2) continue
|
||||
for (const setting of settings) {
|
||||
conflicts.set(
|
||||
setting.option_id,
|
||||
settings
|
||||
.filter((candidate) => candidate.option_id !== setting.option_id)
|
||||
.map((candidate) => settingLabel(candidate)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
})
|
||||
|
||||
const enableCandidates = computed(() =>
|
||||
categorySettings.value.filter((setting) => !setting.sync_enabled && settingCanBeEnabled(setting)),
|
||||
)
|
||||
const disableCandidates = computed(() =>
|
||||
categorySettings.value.filter((setting) => setting.sync_enabled && !setting.controlled),
|
||||
)
|
||||
const toggleableCategorySettings = computed(() =>
|
||||
categorySettings.value.filter((setting) => !setting.controlled),
|
||||
)
|
||||
const allCategorySettingsSynced = computed(
|
||||
() =>
|
||||
toggleableCategorySettings.value.length > 0 &&
|
||||
toggleableCategorySettings.value.every((setting) => setting.sync_enabled),
|
||||
)
|
||||
const modalTitle = computed(() =>
|
||||
formatMessage(isLocalEditor.value ? messages.title : messages.syncTitle),
|
||||
)
|
||||
|
||||
function categoryIcon(category: GameSettingCategory): Component {
|
||||
return categoryIcons[category.id] ?? SettingsIcon
|
||||
}
|
||||
|
||||
function changeCategory(_fromIndex: number, toIndex: number): boolean {
|
||||
const category = categories.value[toIndex]
|
||||
if (!category) return false
|
||||
activeCategoryId.value = category.id
|
||||
search.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
categories,
|
||||
() => {
|
||||
if (!categories.value.some((category) => category.id === activeCategoryId.value)) {
|
||||
activeCategoryId.value = categories.value[0]?.id ?? 'custom_settings'
|
||||
}
|
||||
const index = categories.value.findIndex((category) => category.id === activeCategoryId.value)
|
||||
if (index >= 0) modal.value?.setTab(index)
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function show() {
|
||||
opened.value = true
|
||||
allowClose = false
|
||||
search.value = ''
|
||||
void load()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
opened.value = false
|
||||
resetEditor()
|
||||
allowClose = false
|
||||
}
|
||||
|
||||
function beforeHide(): boolean {
|
||||
if (allowClose || !isDirty.value) return true
|
||||
void saveBeforeClose()
|
||||
return false
|
||||
}
|
||||
|
||||
async function saveBeforeClose() {
|
||||
const saved = await saveAndWait()
|
||||
if (!saved) return
|
||||
allowClose = true
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
async function openSource(location: RouteLocationRaw) {
|
||||
if (isDirty.value || saving.value) return
|
||||
if (appSettingsModal && !appSettingsModal.close()) return
|
||||
allowClose = true
|
||||
modal.value?.hide()
|
||||
if (instanceSettings?.closeModal) {
|
||||
instanceSettings.closeModal(() => void router.push(location))
|
||||
} else {
|
||||
await router.push(location)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVisibleSync() {
|
||||
const enabled = !allCategorySettingsSynced.value
|
||||
const candidates = enabled ? enableCandidates.value : disableCandidates.value
|
||||
setSyncEnabled(
|
||||
candidates.map((setting) => setting.option_id),
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="categoryTabs"
|
||||
:header="modalTitle"
|
||||
:before-hide="beforeHide"
|
||||
:before-tab-change="changeCategory"
|
||||
:hide-tab-selection="search.trim().length > 0"
|
||||
:on-after-hide="reset"
|
||||
:floating-action-bar-shown="isDirty"
|
||||
max-width="min(1080px, calc(95vw - 2rem))"
|
||||
width="min(1080px, calc(95vw - 2rem))"
|
||||
>
|
||||
<template #sidebar-header>
|
||||
<div class="pb-4">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
:aria-label="formatMessage(messages.search)"
|
||||
wrapper-class="w-full shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex min-h-full min-w-0 flex-col">
|
||||
<div
|
||||
v-if="!isLocalEditor && (loading || loadError || categorySettings.length > 0)"
|
||||
class="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-4 border-0 border-b border-solid border-surface-4 pb-4"
|
||||
>
|
||||
<p class="m-0 min-w-60 flex-1 text-[var(--color-text-default)]">
|
||||
{{ formatMessage(messages.syncedOptionsDescription) }}
|
||||
</p>
|
||||
<div class="ml-auto flex w-48 justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="
|
||||
allCategorySettingsSynced
|
||||
? disableCandidates.length === 0
|
||||
: enableCandidates.length === 0
|
||||
"
|
||||
@click="toggleVisibleSync"
|
||||
>
|
||||
<LinkIcon v-if="allCategorySettingsSynced" aria-hidden="true" />
|
||||
<UnlinkIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(allCategorySettingsSynced ? messages.disableAll : messages.enableAll)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
role="status"
|
||||
:aria-label="formatMessage(messages.loading)"
|
||||
class="flex flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="row in 5"
|
||||
:key="row"
|
||||
class="h-20 animate-pulse rounded-xl bg-surface-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
class="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
<p class="m-0">{{ formatMessage(messages.loadFailed) }}</p>
|
||||
<Button @click="load">
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1">
|
||||
<div
|
||||
v-if="categorySettings.length === 0"
|
||||
class="flex h-full min-h-40 items-center justify-center px-8 text-center text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{
|
||||
activeCategory?.is_custom && !search
|
||||
? formatMessage(messages.customEmpty)
|
||||
: formatMessage(messages.empty)
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<GameSettingRow
|
||||
v-for="setting in categorySettings"
|
||||
:key="setting.option_id"
|
||||
:setting="setting"
|
||||
:locale-label="localeLabels[setting.option_id]"
|
||||
:keybind-conflicts="keybindConflicts.get(setting.option_id)"
|
||||
:show-sync-toggle="!isLocalEditor"
|
||||
:source-navigation-disabled="isDirty || saving"
|
||||
@open-source="openSource"
|
||||
@update:sync-enabled="setSyncEnabled([setting.option_id], $event)"
|
||||
@update:canonical-value="setCanonicalValue(setting.option_id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #floating-action-bar>
|
||||
<FloatingActionBar v-if="isDirty" shown inline :aria-label="modalTitle">
|
||||
<p class="m-0 text-sm font-semibold md:text-base">
|
||||
{{ formatMessage(messages.unsavedChanges) }}
|
||||
</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Button type="outlined" @click="cancelChanges">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!isDirty || loading || saving || hasBlockingDraft"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.saveSettings) }}
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue'
|
||||
|
||||
import {
|
||||
activateKeybindRecording,
|
||||
deactivateKeybindRecording,
|
||||
formatMinecraftKeybind,
|
||||
minecraftKeyTokenFromKeyboardEvent,
|
||||
minecraftMouseTokenFromButton,
|
||||
} from './keybinds'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
settingLabel: string
|
||||
conflicts?: string[]
|
||||
mixed?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
conflicts: () => [],
|
||||
mixed: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [value: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const statusId = useId()
|
||||
const conflictId = useId()
|
||||
const recording = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const controlElement = ref<HTMLElement | null>(null)
|
||||
let suppressNextClick = false
|
||||
|
||||
const messages = defineMessages({
|
||||
listen: {
|
||||
id: 'app.settings.game-options.keybind.listen',
|
||||
defaultMessage: 'Press a key…',
|
||||
},
|
||||
choose: {
|
||||
id: 'app.settings.game-options.keybind.choose',
|
||||
defaultMessage: 'Choose a key',
|
||||
},
|
||||
mixed: {
|
||||
id: 'app.settings.game-options.keybind.mixed',
|
||||
defaultMessage: 'Different across instances',
|
||||
},
|
||||
change: {
|
||||
id: 'app.settings.game-options.keybind.change',
|
||||
defaultMessage: '{setting}: {binding}. Activate to change the binding.',
|
||||
},
|
||||
listeningStatus: {
|
||||
id: 'app.settings.game-options.keybind.listening-status',
|
||||
defaultMessage: 'Listening for a key or mouse button. Press Escape to clear this binding.',
|
||||
},
|
||||
recordingLabel: {
|
||||
id: 'app.settings.game-options.keybind.recording-label',
|
||||
defaultMessage:
|
||||
'{setting}: listening for a key or mouse button. Press Escape to clear this binding.',
|
||||
},
|
||||
unsupportedStatus: {
|
||||
id: 'app.settings.game-options.keybind.unsupported-status',
|
||||
defaultMessage: 'That input cannot be used by Minecraft. Press another key.',
|
||||
},
|
||||
cancelledStatus: {
|
||||
id: 'app.settings.game-options.keybind.cancelled-status',
|
||||
defaultMessage: 'Key binding change cancelled.',
|
||||
},
|
||||
assignedStatus: {
|
||||
id: 'app.settings.game-options.keybind.assigned-status',
|
||||
defaultMessage: '{setting} is now bound to {binding}.',
|
||||
},
|
||||
conflict: {
|
||||
id: 'app.settings.game-options.keybind.conflict-description',
|
||||
defaultMessage: 'This is already used by {settings}.',
|
||||
},
|
||||
})
|
||||
|
||||
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||||
const bindingLabel = computed(() => {
|
||||
if (props.mixed) return formatMessage(messages.mixed)
|
||||
if (!props.modelValue) return formatMessage(messages.choose)
|
||||
return formatMinecraftKeybind(formatMessage, props.modelValue, isMac)
|
||||
})
|
||||
const visibleLabel = computed(() =>
|
||||
recording.value ? formatMessage(messages.listen) : bindingLabel.value,
|
||||
)
|
||||
const conflictSettings = computed(() =>
|
||||
new Intl.ListFormat(locale.value, { style: 'long', type: 'conjunction' }).format(
|
||||
props.conflicts.map((setting) => `‘${setting}’`),
|
||||
),
|
||||
)
|
||||
const conflictMessage = computed(() =>
|
||||
props.conflicts.length
|
||||
? formatMessage(messages.conflict, { settings: conflictSettings.value })
|
||||
: '',
|
||||
)
|
||||
const accessibleLabel = computed(() =>
|
||||
formatMessage(recording.value ? messages.recordingLabel : messages.change, {
|
||||
setting: props.settingLabel,
|
||||
binding: bindingLabel.value,
|
||||
}),
|
||||
)
|
||||
|
||||
function startRecording() {
|
||||
if (props.disabled || recording.value) return
|
||||
activateKeybindRecording(cancelRecording)
|
||||
recording.value = true
|
||||
statusMessage.value = formatMessage(messages.listeningStatus)
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
window.addEventListener('pointerdown', handlePointerDown, true)
|
||||
window.addEventListener('blur', cancelRecording)
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
recording.value = false
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
window.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
window.removeEventListener('blur', cancelRecording)
|
||||
deactivateKeybindRecording(cancelRecording)
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (!recording.value) return
|
||||
stopRecording()
|
||||
statusMessage.value = formatMessage(messages.cancelledStatus)
|
||||
}
|
||||
|
||||
function assign(value: string) {
|
||||
if (!recording.value) return
|
||||
stopRecording()
|
||||
emit('update:model-value', value)
|
||||
statusMessage.value = formatMessage(messages.assignedStatus, {
|
||||
setting: props.settingLabel,
|
||||
binding: formatMinecraftKeybind(formatMessage, value, isMac),
|
||||
})
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (suppressNextClick) {
|
||||
suppressNextClick = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
startRecording()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!recording.value) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.code === 'Escape') {
|
||||
assign('key.keyboard.unknown')
|
||||
return
|
||||
}
|
||||
if (event.repeat) return
|
||||
|
||||
const token = event.isComposing ? null : minecraftKeyTokenFromKeyboardEvent(event)
|
||||
if (!token) {
|
||||
statusMessage.value = formatMessage(messages.unsupportedStatus)
|
||||
return
|
||||
}
|
||||
assign(token)
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!recording.value) return
|
||||
if (event.pointerType !== 'mouse') return
|
||||
const otherKeybindControl =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('[data-game-keybind-control]')
|
||||
: null
|
||||
if (otherKeybindControl && otherKeybindControl !== controlElement.value) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const token = minecraftMouseTokenFromButton(event.button)
|
||||
if (!token) {
|
||||
statusMessage.value = formatMessage(messages.unsupportedStatus)
|
||||
return
|
||||
}
|
||||
suppressNextClick = event.button === 0 && otherKeybindControl === controlElement.value
|
||||
assign(token)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) cancelRecording()
|
||||
},
|
||||
)
|
||||
onBeforeUnmount(cancelRecording)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
ref="controlElement"
|
||||
v-tooltip="!recording && conflictMessage ? conflictMessage : undefined"
|
||||
data-game-keybind-control
|
||||
class="flex min-w-0 flex-1"
|
||||
>
|
||||
<Button
|
||||
:type="recording || conflicts.length ? 'outlined' : 'base'"
|
||||
:color="recording ? 'brand' : conflicts.length ? 'orange' : undefined"
|
||||
size="md"
|
||||
:disabled="disabled"
|
||||
:aria-label="accessibleLabel"
|
||||
:aria-describedby="recording ? statusId : conflicts.length ? conflictId : undefined"
|
||||
class="w-full overflow-hidden"
|
||||
@click="handleClick"
|
||||
@contextmenu.prevent.stop
|
||||
@auxclick.prevent.stop
|
||||
>
|
||||
<TriangleAlertIcon v-if="!recording && conflicts.length" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">{{ visibleLabel }}</span>
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
<span :id="statusId" class="visually-hidden" role="status" aria-live="polite">
|
||||
{{ statusMessage }}
|
||||
</span>
|
||||
<span v-if="conflicts.length" :id="conflictId" class="visually-hidden">
|
||||
{{ conflictMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui'
|
||||
|
||||
type FormatMessage = VIntlFormatters['formatMessage']
|
||||
|
||||
type StopKeybindRecording = () => void
|
||||
let stopActiveKeybindRecording: StopKeybindRecording | null = null
|
||||
|
||||
export function activateKeybindRecording(stop: StopKeybindRecording) {
|
||||
if (stopActiveKeybindRecording === stop) return
|
||||
const stopPrevious = stopActiveKeybindRecording
|
||||
stopActiveKeybindRecording = stop
|
||||
stopPrevious?.()
|
||||
}
|
||||
|
||||
export function deactivateKeybindRecording(stop: StopKeybindRecording) {
|
||||
if (stopActiveKeybindRecording === stop) {
|
||||
stopActiveKeybindRecording = null
|
||||
}
|
||||
}
|
||||
|
||||
const keyMessages = defineMessages({
|
||||
unbound: {
|
||||
id: 'app.settings.game-options.keybind.key.unbound',
|
||||
defaultMessage: 'Unbound',
|
||||
},
|
||||
escape: {
|
||||
id: 'app.settings.game-options.keybind.key.escape',
|
||||
defaultMessage: 'Escape',
|
||||
},
|
||||
backspace: {
|
||||
id: 'app.settings.game-options.keybind.key.backspace',
|
||||
defaultMessage: 'Backspace',
|
||||
},
|
||||
tab: {
|
||||
id: 'app.settings.game-options.keybind.key.tab',
|
||||
defaultMessage: 'Tab',
|
||||
},
|
||||
enter: {
|
||||
id: 'app.settings.game-options.keybind.key.enter',
|
||||
defaultMessage: 'Enter',
|
||||
},
|
||||
leftControl: {
|
||||
id: 'app.settings.game-options.keybind.key.left-control',
|
||||
defaultMessage: 'Left Ctrl',
|
||||
},
|
||||
rightControl: {
|
||||
id: 'app.settings.game-options.keybind.key.right-control',
|
||||
defaultMessage: 'Right Ctrl',
|
||||
},
|
||||
leftShift: {
|
||||
id: 'app.settings.game-options.keybind.key.left-shift',
|
||||
defaultMessage: 'Left Shift',
|
||||
},
|
||||
rightShift: {
|
||||
id: 'app.settings.game-options.keybind.key.right-shift',
|
||||
defaultMessage: 'Right Shift',
|
||||
},
|
||||
leftAlt: {
|
||||
id: 'app.settings.game-options.keybind.key.left-alt',
|
||||
defaultMessage: 'Left Alt',
|
||||
},
|
||||
rightAlt: {
|
||||
id: 'app.settings.game-options.keybind.key.right-alt',
|
||||
defaultMessage: 'Right Alt',
|
||||
},
|
||||
space: {
|
||||
id: 'app.settings.game-options.keybind.key.space',
|
||||
defaultMessage: 'Space',
|
||||
},
|
||||
capsLock: {
|
||||
id: 'app.settings.game-options.keybind.key.caps-lock',
|
||||
defaultMessage: 'Caps Lock',
|
||||
},
|
||||
numLock: {
|
||||
id: 'app.settings.game-options.keybind.key.num-lock',
|
||||
defaultMessage: 'Num Lock',
|
||||
},
|
||||
scrollLock: {
|
||||
id: 'app.settings.game-options.keybind.key.scroll-lock',
|
||||
defaultMessage: 'Scroll Lock',
|
||||
},
|
||||
printScreen: {
|
||||
id: 'app.settings.game-options.keybind.key.print-screen',
|
||||
defaultMessage: 'Print Screen',
|
||||
},
|
||||
pause: {
|
||||
id: 'app.settings.game-options.keybind.key.pause',
|
||||
defaultMessage: 'Pause',
|
||||
},
|
||||
home: {
|
||||
id: 'app.settings.game-options.keybind.key.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
end: {
|
||||
id: 'app.settings.game-options.keybind.key.end',
|
||||
defaultMessage: 'End',
|
||||
},
|
||||
pageUp: {
|
||||
id: 'app.settings.game-options.keybind.key.page-up',
|
||||
defaultMessage: 'Page Up',
|
||||
},
|
||||
pageDown: {
|
||||
id: 'app.settings.game-options.keybind.key.page-down',
|
||||
defaultMessage: 'Page Down',
|
||||
},
|
||||
insert: {
|
||||
id: 'app.settings.game-options.keybind.key.insert',
|
||||
defaultMessage: 'Insert',
|
||||
},
|
||||
delete: {
|
||||
id: 'app.settings.game-options.keybind.key.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
arrowUp: {
|
||||
id: 'app.settings.game-options.keybind.key.arrow-up',
|
||||
defaultMessage: 'Up Arrow',
|
||||
},
|
||||
arrowDown: {
|
||||
id: 'app.settings.game-options.keybind.key.arrow-down',
|
||||
defaultMessage: 'Down Arrow',
|
||||
},
|
||||
arrowLeft: {
|
||||
id: 'app.settings.game-options.keybind.key.arrow-left',
|
||||
defaultMessage: 'Left Arrow',
|
||||
},
|
||||
arrowRight: {
|
||||
id: 'app.settings.game-options.keybind.key.arrow-right',
|
||||
defaultMessage: 'Right Arrow',
|
||||
},
|
||||
leftSuper: {
|
||||
id: 'app.settings.game-options.keybind.key.left-super',
|
||||
defaultMessage: 'Left Super',
|
||||
},
|
||||
rightSuper: {
|
||||
id: 'app.settings.game-options.keybind.key.right-super',
|
||||
defaultMessage: 'Right Super',
|
||||
},
|
||||
leftCommand: {
|
||||
id: 'app.settings.game-options.keybind.key.left-command',
|
||||
defaultMessage: 'Left Command',
|
||||
},
|
||||
rightCommand: {
|
||||
id: 'app.settings.game-options.keybind.key.right-command',
|
||||
defaultMessage: 'Right Command',
|
||||
},
|
||||
menu: {
|
||||
id: 'app.settings.game-options.keybind.key.menu',
|
||||
defaultMessage: 'Menu',
|
||||
},
|
||||
keypadKey: {
|
||||
id: 'app.settings.game-options.keybind.key.keypad-key',
|
||||
defaultMessage: 'Numpad {key}',
|
||||
},
|
||||
leftMouse: {
|
||||
id: 'app.settings.game-options.keybind.mouse.left',
|
||||
defaultMessage: 'Left Mouse',
|
||||
},
|
||||
rightMouse: {
|
||||
id: 'app.settings.game-options.keybind.mouse.right',
|
||||
defaultMessage: 'Right Mouse',
|
||||
},
|
||||
middleMouse: {
|
||||
id: 'app.settings.game-options.keybind.mouse.middle',
|
||||
defaultMessage: 'Middle Mouse',
|
||||
},
|
||||
mouseButton: {
|
||||
id: 'app.settings.game-options.keybind.mouse.button',
|
||||
defaultMessage: 'Mouse {button}',
|
||||
},
|
||||
scancode: {
|
||||
id: 'app.settings.game-options.keybind.scancode',
|
||||
defaultMessage: 'Scancode {code}',
|
||||
},
|
||||
unknownKey: {
|
||||
id: 'app.settings.game-options.keybind.key.unknown',
|
||||
defaultMessage: 'Unknown key',
|
||||
},
|
||||
controlModifier: {
|
||||
id: 'app.settings.game-options.keybind.modifier.control',
|
||||
defaultMessage: 'Ctrl',
|
||||
},
|
||||
shiftModifier: {
|
||||
id: 'app.settings.game-options.keybind.modifier.shift',
|
||||
defaultMessage: 'Shift',
|
||||
},
|
||||
altModifier: {
|
||||
id: 'app.settings.game-options.keybind.modifier.alt',
|
||||
defaultMessage: 'Alt',
|
||||
},
|
||||
commandModifier: {
|
||||
id: 'app.settings.game-options.keybind.modifier.command',
|
||||
defaultMessage: 'Command',
|
||||
},
|
||||
superModifier: {
|
||||
id: 'app.settings.game-options.keybind.modifier.super',
|
||||
defaultMessage: 'Super',
|
||||
},
|
||||
})
|
||||
|
||||
const keyboardCodeTokens: Record<string, string> = {
|
||||
Escape: 'escape',
|
||||
Minus: 'minus',
|
||||
Equal: 'equal',
|
||||
Backspace: 'backspace',
|
||||
Tab: 'tab',
|
||||
BracketLeft: 'left.bracket',
|
||||
BracketRight: 'right.bracket',
|
||||
Enter: 'enter',
|
||||
ControlLeft: 'left.control',
|
||||
Semicolon: 'semicolon',
|
||||
Quote: 'apostrophe',
|
||||
Backquote: 'grave.accent',
|
||||
ShiftLeft: 'left.shift',
|
||||
Backslash: 'backslash',
|
||||
Comma: 'comma',
|
||||
Period: 'period',
|
||||
Slash: 'slash',
|
||||
ShiftRight: 'right.shift',
|
||||
AltLeft: 'left.alt',
|
||||
Space: 'space',
|
||||
CapsLock: 'caps.lock',
|
||||
NumLock: 'num.lock',
|
||||
ScrollLock: 'scroll.lock',
|
||||
NumpadSubtract: 'keypad.subtract',
|
||||
NumpadAdd: 'keypad.add',
|
||||
NumpadDecimal: 'keypad.decimal',
|
||||
NumpadComma: 'keypad.decimal',
|
||||
NumpadEqual: 'keypad.equal',
|
||||
NumpadEnter: 'keypad.enter',
|
||||
ControlRight: 'right.control',
|
||||
NumpadDivide: 'keypad.divide',
|
||||
NumpadMultiply: 'keypad.multiply',
|
||||
PrintScreen: 'print.screen',
|
||||
AltRight: 'right.alt',
|
||||
Pause: 'pause',
|
||||
Home: 'home',
|
||||
ArrowUp: 'up',
|
||||
PageUp: 'page.up',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
End: 'end',
|
||||
ArrowDown: 'down',
|
||||
PageDown: 'page.down',
|
||||
Insert: 'insert',
|
||||
Delete: 'delete',
|
||||
MetaLeft: 'left.win',
|
||||
MetaRight: 'right.win',
|
||||
ContextMenu: 'menu',
|
||||
}
|
||||
|
||||
const keyboardLabels: Record<string, MessageDescriptor | string> = {
|
||||
escape: keyMessages.escape,
|
||||
minus: '-',
|
||||
equal: '=',
|
||||
backspace: keyMessages.backspace,
|
||||
tab: keyMessages.tab,
|
||||
'left.bracket': '[',
|
||||
'right.bracket': ']',
|
||||
enter: keyMessages.enter,
|
||||
'left.control': keyMessages.leftControl,
|
||||
semicolon: ';',
|
||||
apostrophe: "'",
|
||||
'grave.accent': '`',
|
||||
'left.shift': keyMessages.leftShift,
|
||||
backslash: '\\',
|
||||
comma: ',',
|
||||
period: '.',
|
||||
slash: '/',
|
||||
'right.shift': keyMessages.rightShift,
|
||||
'left.alt': keyMessages.leftAlt,
|
||||
space: keyMessages.space,
|
||||
'caps.lock': keyMessages.capsLock,
|
||||
'num.lock': keyMessages.numLock,
|
||||
'scroll.lock': keyMessages.scrollLock,
|
||||
'keypad.subtract': '-',
|
||||
'keypad.add': '+',
|
||||
'keypad.decimal': '.',
|
||||
'keypad.equal': '=',
|
||||
'keypad.enter': keyMessages.enter,
|
||||
'right.control': keyMessages.rightControl,
|
||||
'keypad.divide': '/',
|
||||
'keypad.multiply': '*',
|
||||
'print.screen': keyMessages.printScreen,
|
||||
'right.alt': keyMessages.rightAlt,
|
||||
pause: keyMessages.pause,
|
||||
home: keyMessages.home,
|
||||
up: keyMessages.arrowUp,
|
||||
'page.up': keyMessages.pageUp,
|
||||
left: keyMessages.arrowLeft,
|
||||
right: keyMessages.arrowRight,
|
||||
end: keyMessages.end,
|
||||
down: keyMessages.arrowDown,
|
||||
'page.down': keyMessages.pageDown,
|
||||
insert: keyMessages.insert,
|
||||
delete: keyMessages.delete,
|
||||
menu: keyMessages.menu,
|
||||
}
|
||||
|
||||
function splitKeybind(value: string): [key: string, modifier?: string] {
|
||||
const separator = value.indexOf(':')
|
||||
return separator === -1 ? [value] : [value.slice(0, separator), value.slice(separator + 1)]
|
||||
}
|
||||
|
||||
function keyboardIdentifierFromCode(code: string): string | null {
|
||||
const letter = /^Key([A-Z])$/.exec(code)
|
||||
if (letter) return letter[1].toLowerCase()
|
||||
|
||||
const digit = /^Digit([0-9])$/.exec(code)
|
||||
if (digit) return digit[1]
|
||||
|
||||
const functionKey = /^F([1-9]|1[0-9]|2[0-5])$/.exec(code)
|
||||
if (functionKey) return `f${functionKey[1]}`
|
||||
|
||||
const keypadDigit = /^Numpad([0-9])$/.exec(code)
|
||||
if (keypadDigit) return `keypad.${keypadDigit[1]}`
|
||||
|
||||
return keyboardCodeTokens[code] ?? null
|
||||
}
|
||||
|
||||
export function minecraftKeyTokenFromKeyboardEvent(event: KeyboardEvent): string | null {
|
||||
const identifier = keyboardIdentifierFromCode(event.code)
|
||||
return identifier ? `key.keyboard.${identifier}` : null
|
||||
}
|
||||
|
||||
export function minecraftMouseTokenFromButton(button: number): string | null {
|
||||
if (!Number.isInteger(button) || button < 0 || button > 15) return null
|
||||
if (button === 0) return 'key.mouse.left'
|
||||
if (button === 1) return 'key.mouse.middle'
|
||||
if (button === 2) return 'key.mouse.right'
|
||||
return `key.mouse.${button + 1}`
|
||||
}
|
||||
|
||||
function formatKeyboardLabel(
|
||||
formatMessage: FormatMessage,
|
||||
identifier: string,
|
||||
isMac: boolean,
|
||||
): string {
|
||||
if (identifier === 'unknown') return formatMessage(keyMessages.unbound)
|
||||
if (/^[a-z]$/.test(identifier)) return identifier.toUpperCase()
|
||||
if (/^[0-9]$/.test(identifier)) return identifier
|
||||
if (/^f([1-9]|1[0-9]|2[0-5])$/.test(identifier)) return identifier.toUpperCase()
|
||||
|
||||
const keypad = /^keypad\.(.+)$/.exec(identifier)
|
||||
if (keypad) {
|
||||
const label = keyboardLabels[identifier]
|
||||
const key = typeof label === 'string' ? label : label ? formatMessage(label) : keypad[1]
|
||||
return formatMessage(keyMessages.keypadKey, { key })
|
||||
}
|
||||
|
||||
if (identifier === 'left.win') {
|
||||
return formatMessage(isMac ? keyMessages.leftCommand : keyMessages.leftSuper)
|
||||
}
|
||||
if (identifier === 'right.win') {
|
||||
return formatMessage(isMac ? keyMessages.rightCommand : keyMessages.rightSuper)
|
||||
}
|
||||
|
||||
const label = keyboardLabels[identifier]
|
||||
if (typeof label === 'string') return label
|
||||
if (label) return formatMessage(label)
|
||||
return identifier
|
||||
.split('.')
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function formatModifier(formatMessage: FormatMessage, modifier: string, isMac: boolean): string {
|
||||
switch (modifier.toUpperCase()) {
|
||||
case 'CONTROL':
|
||||
case 'CTRL':
|
||||
return formatMessage(keyMessages.controlModifier)
|
||||
case 'SHIFT':
|
||||
return formatMessage(keyMessages.shiftModifier)
|
||||
case 'ALT':
|
||||
return formatMessage(keyMessages.altModifier)
|
||||
case 'META':
|
||||
case 'SUPER':
|
||||
return formatMessage(isMac ? keyMessages.commandModifier : keyMessages.superModifier)
|
||||
default:
|
||||
return modifier
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMinecraftKeybind(
|
||||
formatMessage: FormatMessage,
|
||||
value: string,
|
||||
isMac: boolean,
|
||||
): string {
|
||||
const [key, modifier] = splitKeybind(value)
|
||||
let label: string
|
||||
|
||||
if (key.startsWith('key.keyboard.')) {
|
||||
label = formatKeyboardLabel(formatMessage, key.slice('key.keyboard.'.length), isMac)
|
||||
} else if (key === 'key.mouse.left') {
|
||||
label = formatMessage(keyMessages.leftMouse)
|
||||
} else if (key === 'key.mouse.right') {
|
||||
label = formatMessage(keyMessages.rightMouse)
|
||||
} else if (key === 'key.mouse.middle') {
|
||||
label = formatMessage(keyMessages.middleMouse)
|
||||
} else if (key.startsWith('key.mouse.')) {
|
||||
label = formatMessage(keyMessages.mouseButton, {
|
||||
button: key.slice('key.mouse.'.length),
|
||||
})
|
||||
} else if (key.startsWith('scancode.')) {
|
||||
label = formatMessage(keyMessages.scancode, { code: key.slice('scancode.'.length) })
|
||||
} else {
|
||||
label = formatMessage(keyMessages.unknownKey)
|
||||
}
|
||||
|
||||
if (!modifier || key === 'key.keyboard.unknown') return label
|
||||
const modifiers = modifier
|
||||
.split(/[+:]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => formatModifier(formatMessage, part, isMac))
|
||||
return [...modifiers, label].join(' + ')
|
||||
}
|
||||
|
||||
export function minecraftKeybindConflictKey(optionId: string, value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized || normalized === 'key.keyboard.unknown') return null
|
||||
|
||||
if (optionId === 'key.debug.modifier') return null
|
||||
if (optionId.startsWith('key.debug.') && optionId !== 'key.debug.overlay') {
|
||||
return `debug:${normalized}`
|
||||
}
|
||||
return `direct:${normalized}`
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import type { ComboboxOption } from '@modrinth/ui'
|
||||
|
||||
/** Java Edition language names and regions from https://github.com/misode/mcmeta/blob/assets/pack.mcmeta. */
|
||||
const languageNames: Record<string, string> = {
|
||||
en_us: 'English (US)',
|
||||
af_za: 'Afrikaans (Suid-Afrika)',
|
||||
esan: 'Andalûh (Andaluçía)',
|
||||
enp: 'Anglish (Oned Riches)',
|
||||
ast_es: 'Asturianu (Asturies)',
|
||||
az_az: 'Azərbaycanca (Azərbaycan)',
|
||||
id_id: 'Bahasa Indonesia (Indonesia)',
|
||||
ms_my: 'Bahasa Melayu (Malaysia)',
|
||||
tzo_mx: "Bats'i k'op (Jobel)",
|
||||
qid: 'Bhs. Indonesia edjaän lama (Indonesia tempo doeloe)',
|
||||
be_latn: 'Biełaruskaja (Biełaruś)',
|
||||
bar: 'Boarisch (Bayern)',
|
||||
bs_ba: 'Bosanski (Bosna i Hercegovina)',
|
||||
brb: 'Braobans (Braobant)',
|
||||
br_fr: 'Brezhoneg (Breizh)',
|
||||
ca_es: 'Català (Catalunya)',
|
||||
val_es: 'Català (Valencià) (País Valencià)',
|
||||
cy_gb: 'Cymraeg (Cymru)',
|
||||
qcb_es: 'Cántabru/Montañés (Cantabria)',
|
||||
da_dk: 'Dansk (Danmark)',
|
||||
se_no: 'Davvisámegiella (Sápmi)',
|
||||
de_at: 'Deitsch (Österreich)',
|
||||
de_de: 'Deutsch (Deutschland)',
|
||||
et_ee: 'Eesti keel (Eesti)',
|
||||
en_au: 'English (Australia)',
|
||||
en_ca: 'English (Canada)',
|
||||
en_nz: 'English (New Zealand)',
|
||||
en_gb: 'English (United Kingdom)',
|
||||
es_ar: 'Español (Argentina)',
|
||||
es_cl: 'Español (Chile)',
|
||||
es_ec: 'Español (Ecuador)',
|
||||
es_es: 'Español (España)',
|
||||
es_mx: 'Español (México)',
|
||||
es_uy: 'Español (Uruguay)',
|
||||
es_ve: 'Español (Venezuela)',
|
||||
eo_uy: 'Esperanto (Esperantujo)',
|
||||
eu_es: 'Euskara (Euskal Herria)',
|
||||
fil_ph: 'Filipino (Pilipinas)',
|
||||
fr_ca: 'Français (Canada)',
|
||||
fr_fr: 'Français (France)',
|
||||
fr_ch: 'Français (Suisse)',
|
||||
fy_nl: 'Frysk (Fryslân)',
|
||||
fra_de: 'Fränggisch (Franggn)',
|
||||
fur_it: 'Furlan (Friûl)',
|
||||
fo_fo: 'Føroyskt (Føroyar)',
|
||||
ga_ie: 'Gaeilge (Éire)',
|
||||
gl_es: 'Galego (Galicia / Galiza)',
|
||||
go_fr: 'Galo (Bertègn)',
|
||||
gd_gb: 'Gàidhlig (Alba)',
|
||||
hr_hr: 'Hrvatski (Hrvatska)',
|
||||
hn_no: 'Høgnorsk (Norig)',
|
||||
io_en: 'Ido (Idia)',
|
||||
ig_ng: 'Igbo (Naigeria)',
|
||||
it_it: 'Italiano (Italia)',
|
||||
kw_gb: 'Kernewek (Kernow)',
|
||||
ksh: 'Kölsch/Ripoarisch (Rhingland)',
|
||||
lol_us: 'LOLCAT (Kingdom of Cats)',
|
||||
la_la: 'Latina (Latium)',
|
||||
lv_lv: 'Latviešu (Latvija)',
|
||||
lt_lt: 'Lietuvių (Lietuva)',
|
||||
li_li: 'Limburgs (Limburg)',
|
||||
lmo: 'Lombard (Lombardia)',
|
||||
lb_lu: 'Lëtzebuergesch (Lëtzebuerg)',
|
||||
hu_hu: 'Magyar (Magyarország)',
|
||||
mt_mt: 'Malti (Malta)',
|
||||
isv: 'Medžuslovjansky (Slovjanščina)',
|
||||
nah: 'Mēxikatlahtōlli (Mēxiko)',
|
||||
nl_nl: 'Nederlands (Nederland)',
|
||||
pls: 'Ngiiwà (Ndanìꞌngà)',
|
||||
no_no: 'Norsk bokmål (Norge)',
|
||||
nn_no: 'Norsk nynorsk (Noreg)',
|
||||
uz_uz: "O'zbekcha (O'zbekiston)",
|
||||
oc_fr: 'Occitan (Occitània)',
|
||||
en_pt: 'Pirate Speak (The Seven Seas)',
|
||||
nds_de: 'Plattdüütsch (Düütschland)',
|
||||
pl_pl: 'Polski (Polska)',
|
||||
pt_br: 'Português (Brasil)',
|
||||
pt_pt: 'Português (Portugal)',
|
||||
qya_aa: 'Quenya (Arda)',
|
||||
ro_ro: 'Română (România)',
|
||||
de_ch: 'Schwiizerdütsch (Schwiiz)',
|
||||
enws: 'Shakespearean English (Kingdom of England)',
|
||||
sq_al: 'Shqip (Shqipëri)',
|
||||
sk_sk: 'Slovenčina (Slovensko)',
|
||||
sl_si: 'Slovenščina (Slovenija)',
|
||||
so_so: 'Soomaali (Soomaaliya)',
|
||||
sr_cs: 'Srpski (Srbija)',
|
||||
fi_fi: 'Suomi (Suomi)',
|
||||
sv_se: 'Svenska (Sverige)',
|
||||
sxu: 'Säggs’sch (Saggsn)',
|
||||
tl_ph: 'Tagalog (Pilipinas)',
|
||||
vi_vn: 'Tiếng Việt (Việt Nam)',
|
||||
tr_tr: 'Türkçe (Türkiye)',
|
||||
vp_vl: 'Viossa (Vilant)',
|
||||
nl_be: 'Vlaams (België)',
|
||||
vec_it: 'Vèneto (Veneto)',
|
||||
vro: 'Võro (Eesti)',
|
||||
yo_ng: 'Yorùbá (Nàìjíríà)',
|
||||
jbo_en: "la .lojban. (la jbogu'e)",
|
||||
tlh_aa: "tlhIngan Hol (tlhIngan wo')",
|
||||
tok: 'toki pona (kulupu pona)',
|
||||
is_is: 'Íslenska (Ísland)',
|
||||
ovd: 'Övdalska (Swerre)',
|
||||
cs_cz: 'Čeština (Česko)',
|
||||
szl: 'Ślōnski (Gōrny Ślōnsk)',
|
||||
en_ud: 'ɥsᴉꞁᵷuƎ (uʍoᗡ ǝpᴉsd∩)',
|
||||
haw_us: 'ʻŌlelo Hawaiʻi (Hawaiʻi)',
|
||||
el_gr: 'Ελληνικά (Ελλάδα)',
|
||||
ba_ru: 'Башҡортса (Башҡортостан, Рәсәй)',
|
||||
be_by: 'Беларуская (Беларусь)',
|
||||
bg_bg: 'Български (България)',
|
||||
hal_ua: 'Галицка (Галичина, Вкраїна)',
|
||||
ky_kg: 'Кыргызча (Кыргызстан)',
|
||||
mk_mk: 'Македонски (Северна Македонија)',
|
||||
mn_mn: 'Монгол (Монгол Улс)',
|
||||
ry_ua: 'Руснацькый (Пудкарпатя, Украина)',
|
||||
ru_ru: 'Русский (Россия)',
|
||||
rpr: 'Русскій дореформенный (Россійская имперія)',
|
||||
sah_sah: 'Сахалыы (Cаха Сирэ)',
|
||||
sr_sp: 'Српски (Србија)',
|
||||
tt_ru: 'Татарча (Татарстан, Рәсәй)',
|
||||
uk_ua: 'Українська (Україна)',
|
||||
cv_cu: 'Чӑвашла (Чӑваш Ен, Раҫҫей)',
|
||||
kk_kz: 'Қазақша (Қазақстан)',
|
||||
hy_am: 'Հայերեն (Հայաստան)',
|
||||
yi_de: 'ייִדיש (אשכנזיש יידן)',
|
||||
he_il: 'עברית (ישראל)',
|
||||
ar_sa: 'العربية (العالم العربي)',
|
||||
zlm_arab: 'بهاس ملايو (مليسيا)',
|
||||
fa_ir: 'فارسی (ايران)',
|
||||
hi_in: 'हिंदी (भारत)',
|
||||
ta_in: 'தமிழ் (இந்தியா)',
|
||||
kn_in: 'ಕನ್ನಡ (ಭಾರತ)',
|
||||
th_th: 'ไทย (ประเทศไทย)',
|
||||
lo_la: 'ລາວ (ປະເທດລາວ)',
|
||||
ka_ge: 'ქართული (საქართველო)',
|
||||
lzh: '文言 (華夏)',
|
||||
ja_jp: '日本語 (日本)',
|
||||
zh_cn: '简体中文 (中国大陆)',
|
||||
zh_tw: '繁體中文 (台灣)',
|
||||
zh_hk: '繁體中文 (香港特別行政區)',
|
||||
ko_kr: '한국어 (대한민국)',
|
||||
got_de: '𐌲𐌿𐍄𐍂𐌰𐌶𐌳𐌰 (𐌲𐌿𐍄𐌸𐌹𐌿𐌳𐌰)',
|
||||
}
|
||||
|
||||
export const minecraftLanguageOptions: ComboboxOption<string>[] = Object.entries(languageNames)
|
||||
.map(([value, label]) => ({ value, label, searchTerms: [value] }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
+1428
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,384 @@
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon, UnknownIcon, UnlinkIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
Slider,
|
||||
StyledInput,
|
||||
truncatedTooltip,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { type RouteLocationRaw, RouterLink } from 'vue-router'
|
||||
|
||||
import type {
|
||||
EditableGameSetting,
|
||||
GameOptionCanonicalValue,
|
||||
GameSettingLocaleLabel,
|
||||
} from '@/helpers/game-options'
|
||||
|
||||
import GameSettingBooleanControl from './boolean-control.vue'
|
||||
import {
|
||||
canonicalBooleanValue,
|
||||
canonicalValueFromInput,
|
||||
canonicalValueText,
|
||||
gameSettingNumberScale,
|
||||
isKeybindSetting,
|
||||
settingCanBeEnabled,
|
||||
} from './editors'
|
||||
import GameKeybindInput from './keybind-input.vue'
|
||||
import { minecraftLanguageOptions } from './languages'
|
||||
import {
|
||||
formatGameSettingChoice,
|
||||
formatGameSettingDescription,
|
||||
formatGameSettingLabel,
|
||||
formatGameSettingValidation,
|
||||
presentationMessages,
|
||||
} from './messages'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
setting: EditableGameSetting
|
||||
localeLabel?: GameSettingLocaleLabel
|
||||
keybindConflicts?: string[]
|
||||
disabled?: boolean
|
||||
showSyncToggle?: boolean
|
||||
sourceNavigationDisabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
keybindConflicts: () => [],
|
||||
disabled: false,
|
||||
showSyncToggle: true,
|
||||
sourceNavigationDisabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sync-enabled': [enabled: boolean]
|
||||
'update:canonical-value': [value: GameOptionCanonicalValue | null]
|
||||
'open-source': [location: RouteLocationRaw]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const settingLabelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
on: {
|
||||
id: 'app.settings.synced-options.game-settings.value-on',
|
||||
defaultMessage: 'On',
|
||||
},
|
||||
off: {
|
||||
id: 'app.settings.synced-options.game-settings.value-off',
|
||||
defaultMessage: 'Off',
|
||||
},
|
||||
mixed: {
|
||||
id: 'app.settings.synced-options.game-settings.value-mixed',
|
||||
defaultMessage: 'Different across instances',
|
||||
},
|
||||
unset: {
|
||||
id: 'app.settings.synced-options.game-settings.value-unset',
|
||||
defaultMessage: 'Choose a value',
|
||||
},
|
||||
syncSetting: {
|
||||
id: 'app.settings.synced-options.game-settings.sync-setting',
|
||||
defaultMessage: 'Sync {setting} across instances',
|
||||
},
|
||||
unsyncSetting: {
|
||||
id: 'app.settings.synced-options.game-settings.unsync-setting',
|
||||
defaultMessage: 'Stop syncing {setting} across instances',
|
||||
},
|
||||
syncedAcrossInstances: {
|
||||
id: 'app.settings.synced-options.game-settings.synced-across-instances',
|
||||
defaultMessage: 'Synced across instances',
|
||||
},
|
||||
notBeingSynced: {
|
||||
id: 'app.settings.synced-options.game-settings.not-being-synced',
|
||||
defaultMessage: 'Not being synced',
|
||||
},
|
||||
})
|
||||
|
||||
const settingLabel = computed(() => {
|
||||
if (props.setting.kind === 'external') {
|
||||
return props.localeLabel?.label ?? formatGameSettingLabel(formatMessage, props.setting)
|
||||
}
|
||||
return formatGameSettingLabel(formatMessage, props.setting)
|
||||
})
|
||||
const settingDescription = computed(() =>
|
||||
formatGameSettingDescription(formatMessage, props.setting),
|
||||
)
|
||||
const settingSource = computed(() =>
|
||||
props.setting.kind === 'external' ? props.localeLabel?.source : undefined,
|
||||
)
|
||||
const sourceLocation = computed<RouteLocationRaw>(() => {
|
||||
const source = settingSource.value
|
||||
if (source?.project) {
|
||||
return { path: `/project/${source.project.id}` }
|
||||
}
|
||||
return {
|
||||
name: 'InstanceContent',
|
||||
params: { id: source?.instance_id },
|
||||
query: { highlight: source?.file_path },
|
||||
}
|
||||
})
|
||||
const valueText = computed(() => canonicalValueText(props.setting))
|
||||
const languageOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
const value = valueText.value
|
||||
if (value && !minecraftLanguageOptions.some((option) => option.value === value)) {
|
||||
return [{ value, label: value }, ...minecraftLanguageOptions]
|
||||
}
|
||||
return minecraftLanguageOptions
|
||||
})
|
||||
const enumOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
(props.setting.editor.choices ?? []).map((choice) => ({
|
||||
value: choice.value,
|
||||
label:
|
||||
props.setting.kind === 'external'
|
||||
? (props.localeLabel?.choices[choice.value] ?? choice.value)
|
||||
: formatGameSettingChoice(formatMessage, props.setting.option_id, choice.value),
|
||||
})),
|
||||
)
|
||||
const isNumber = computed(
|
||||
() => props.setting.editor.type === 'integer' || props.setting.editor.type === 'decimal',
|
||||
)
|
||||
const isSlider = computed(
|
||||
() => isNumber.value && props.setting.editor.min != null && props.setting.editor.max != null,
|
||||
)
|
||||
const booleanValue = computed(() => canonicalBooleanValue(props.setting))
|
||||
const numberScale = computed(() => gameSettingNumberScale(props.setting))
|
||||
const inputMin = computed(() =>
|
||||
props.setting.editor.min === null || props.setting.editor.min === undefined
|
||||
? undefined
|
||||
: props.setting.editor.min * numberScale.value,
|
||||
)
|
||||
const inputMax = computed(() =>
|
||||
props.setting.editor.max === null || props.setting.editor.max === undefined
|
||||
? undefined
|
||||
: props.setting.editor.max * numberScale.value,
|
||||
)
|
||||
const inputStep = computed(() =>
|
||||
props.setting.editor.step === null || props.setting.editor.step === undefined
|
||||
? undefined
|
||||
: props.setting.editor.step * numberScale.value,
|
||||
)
|
||||
const sliderValue = computed(() => {
|
||||
if (valueText.value === '') return null
|
||||
const value = Number(valueText.value)
|
||||
return Number.isFinite(value) ? value : null
|
||||
})
|
||||
const inputValue = computed(() =>
|
||||
isNumber.value ? (sliderValue.value ?? undefined) : valueText.value,
|
||||
)
|
||||
const editorDisabled = computed(
|
||||
() =>
|
||||
props.disabled ||
|
||||
props.setting.controlled ||
|
||||
(props.showSyncToggle && !props.setting.sync_enabled),
|
||||
)
|
||||
const syncToggleDisabled = computed(
|
||||
() =>
|
||||
props.disabled ||
|
||||
props.setting.controlled ||
|
||||
(!props.setting.sync_enabled && !settingCanBeEnabled(props.setting)),
|
||||
)
|
||||
const placeholder = computed(() => {
|
||||
if (props.setting.value_state === 'mixed') return formatMessage(messages.mixed)
|
||||
if (props.setting.value_state === 'unset') return formatMessage(messages.unset)
|
||||
if (props.setting.editor.type === 'external_raw') {
|
||||
return formatMessage(presentationMessages.customValuePlaceholder)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const validationMessage = computed(() =>
|
||||
formatGameSettingValidation(formatMessage, props.setting.validation_error),
|
||||
)
|
||||
const syncDisabledReason = computed(() => {
|
||||
if (validationMessage.value) return validationMessage.value
|
||||
if (props.setting.value_state === 'invalid') {
|
||||
return formatMessage(presentationMessages.validationInvalidValue)
|
||||
}
|
||||
if (props.setting.value_state === 'mixed' || props.setting.value_state === 'unset') {
|
||||
return formatMessage(presentationMessages.validationMissingValue)
|
||||
}
|
||||
if (props.setting.controlled) {
|
||||
return formatMessage(presentationMessages.bucketLauncherControlled, { count: 1 })
|
||||
}
|
||||
if (
|
||||
props.setting.compatibility.total_participating > 0 &&
|
||||
props.setting.compatibility.will_receive === 0
|
||||
) {
|
||||
return formatMessage(presentationMessages.compatibilityNone)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const syncActionLabel = computed(() =>
|
||||
formatMessage(props.setting.sync_enabled ? messages.unsyncSetting : messages.syncSetting, {
|
||||
setting: settingLabel.value,
|
||||
}),
|
||||
)
|
||||
const syncStatusTooltip = computed(() =>
|
||||
formatMessage(
|
||||
props.setting.sync_enabled ? messages.syncedAcrossInstances : messages.notBeingSynced,
|
||||
),
|
||||
)
|
||||
|
||||
function updateValue(value: string | number | boolean | undefined) {
|
||||
emit('update:canonical-value', canonicalValueFromInput(props.setting, value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid min-h-[54px] min-w-0 items-center gap-2"
|
||||
:class="
|
||||
isSlider
|
||||
? showSyncToggle
|
||||
? 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)_2.25rem]'
|
||||
: 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)]'
|
||||
: showSyncToggle
|
||||
? 'grid-cols-[minmax(0,1fr)_12rem_2.25rem]'
|
||||
: 'grid-cols-[minmax(0,1fr)_12rem]'
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 pr-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
ref="settingLabelRef"
|
||||
v-tooltip="truncatedTooltip(settingLabelRef, settingLabel)"
|
||||
class="m-0 truncate text-lg font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ settingLabel }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="validationMessage"
|
||||
v-tooltip="validationMessage"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
class="flex shrink-0 rounded-md text-orange outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
>
|
||||
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<RouterLink v-if="settingSource" v-slot="{ href }" :to="sourceLocation" custom>
|
||||
<a
|
||||
:href="sourceNavigationDisabled ? undefined : href"
|
||||
:aria-disabled="sourceNavigationDisabled || undefined"
|
||||
class="mt-2 inline-flex max-w-full items-center gap-1.5 text-[var(--color-text-default)]"
|
||||
:class="sourceNavigationDisabled ? 'cursor-default' : 'hover:underline'"
|
||||
@click.prevent="!sourceNavigationDisabled && emit('open-source', sourceLocation)"
|
||||
>
|
||||
<Avatar
|
||||
v-if="settingSource.project"
|
||||
:src="settingSource.project.icon_url"
|
||||
size="1.25rem"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="truncate">{{
|
||||
settingSource.project?.title ?? settingSource.file_name
|
||||
}}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
<p v-else-if="settingDescription" class="m-0 mt-0.5 text-[var(--color-text-default)]">
|
||||
{{ settingDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center justify-end">
|
||||
<Slider
|
||||
v-if="isSlider"
|
||||
size="standard"
|
||||
:model-value="sliderValue"
|
||||
:min="inputMin ?? 0"
|
||||
:max="inputMax ?? 100"
|
||||
:step="inputStep ?? 1"
|
||||
:snap-points="setting.category_id === 'music_and_sound' ? [0, 50, 100] : []"
|
||||
:snap-range="5"
|
||||
:unit="setting.editor.unit === 'percent' ? '%' : undefined"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="settingLabel"
|
||||
:disabled="editorDisabled"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
|
||||
<GameSettingBooleanControl
|
||||
v-else-if="setting.editor.type === 'bool'"
|
||||
:model-value="booleanValue"
|
||||
:label="settingLabel"
|
||||
:on-label="formatMessage(messages.on)"
|
||||
:off-label="formatMessage(messages.off)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="editorDisabled"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
v-else-if="setting.option_id === 'language'"
|
||||
:model-value="valueText"
|
||||
:options="languageOptions"
|
||||
:placeholder="placeholder"
|
||||
:disabled="editorDisabled"
|
||||
:aria-label="settingLabel"
|
||||
:search-input-attrs="{ 'aria-label': settingLabel }"
|
||||
searchable
|
||||
select-search-text-on-focus
|
||||
class="min-w-0"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
v-else-if="setting.editor.type === 'enum'"
|
||||
:model-value="valueText"
|
||||
:options="enumOptions"
|
||||
:placeholder="placeholder"
|
||||
:disabled="editorDisabled"
|
||||
:aria-label="settingLabel"
|
||||
class="min-w-0"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
|
||||
<GameKeybindInput
|
||||
v-else-if="isKeybindSetting(setting)"
|
||||
:model-value="valueText"
|
||||
:setting-label="settingLabel"
|
||||
:conflicts="keybindConflicts"
|
||||
:mixed="setting.value_state === 'mixed'"
|
||||
:disabled="editorDisabled"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
v-else
|
||||
:model-value="inputValue"
|
||||
:type="isNumber ? 'number' : 'text'"
|
||||
:min="inputMin"
|
||||
:max="inputMax"
|
||||
:step="inputStep"
|
||||
:placeholder="placeholder"
|
||||
:disabled="editorDisabled"
|
||||
:aria-label="settingLabel"
|
||||
wrapper-class="w-full"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="showSyncToggle"
|
||||
v-tooltip="syncToggleDisabled && syncDisabledReason ? syncDisabledReason : syncStatusTooltip"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<IconButton
|
||||
:type="setting.sync_enabled ? 'chip' : 'base'"
|
||||
:color="setting.sync_enabled ? 'blue' : undefined"
|
||||
:label="syncActionLabel"
|
||||
:disabled="syncToggleDisabled"
|
||||
:aria-pressed="setting.sync_enabled"
|
||||
@click="emit('update:sync-enabled', !setting.sync_enabled)"
|
||||
>
|
||||
<LinkIcon v-if="setting.sync_enabled" aria-hidden="true" />
|
||||
<UnlinkIcon v-else aria-hidden="true" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type MaybeRefOrGetter, nextTick, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
|
||||
import {
|
||||
type EditableGameSetting,
|
||||
type GameOptionCanonicalValue,
|
||||
type GameSettingChange,
|
||||
type GameSettingsEditorState,
|
||||
gameSettingsKeys,
|
||||
gameSettingsQueryOptions,
|
||||
get_local_game_options_config,
|
||||
get_synced_game_options_config,
|
||||
preview_local_game_option_changes,
|
||||
preview_synced_game_option_changes,
|
||||
save_local_game_option_changes,
|
||||
save_synced_game_option_changes,
|
||||
type UpdateGameSettingsRequest,
|
||||
} from '@/helpers/game-options'
|
||||
|
||||
import { canonicalValuesEqual, cloneGameSettingsState, gameSettingChanges } from './editors'
|
||||
|
||||
export function useGameSettingsEditor(
|
||||
instanceId: MaybeRefOrGetter<string | undefined>,
|
||||
onSaved: () => void,
|
||||
) {
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const messages = defineMessages({
|
||||
conflictTitle: {
|
||||
id: 'app.settings.synced-options.game-settings.conflict-title',
|
||||
defaultMessage: 'These settings changed elsewhere',
|
||||
},
|
||||
conflictText: {
|
||||
id: 'app.settings.synced-options.game-settings.conflict-text',
|
||||
defaultMessage: 'We loaded the latest settings. Check your changes, then save again.',
|
||||
},
|
||||
})
|
||||
|
||||
const baseState = ref<GameSettingsEditorState | null>(null)
|
||||
const draftState = ref<GameSettingsEditorState | null>(null)
|
||||
const touchedValueOptionIds = ref<Set<string>>(new Set())
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewGeneration = 0
|
||||
let loadGeneration = 0
|
||||
let saveGeneration = 0
|
||||
|
||||
const editorInstanceId = ref<string>()
|
||||
const isLocalEditor = computed(() => !!editorInstanceId.value)
|
||||
|
||||
const dirtyChanges = computed(() =>
|
||||
gameSettingChanges(baseState.value, draftState.value, touchedValueOptionIds.value),
|
||||
)
|
||||
const isDirty = computed(() => dirtyChanges.value.length > 0)
|
||||
const dirtyOptionIds = computed(
|
||||
() => new Set(dirtyChanges.value.map((change) => change.option_id)),
|
||||
)
|
||||
const hasBlockingDraft = computed(
|
||||
() =>
|
||||
draftState.value?.settings.some(
|
||||
(setting) =>
|
||||
dirtyOptionIds.value.has(setting.option_id) &&
|
||||
(isLocalEditor.value || setting.sync_enabled) &&
|
||||
(!!setting.validation_error ||
|
||||
['mixed', 'unset', 'invalid'].includes(setting.value_state)),
|
||||
) ?? false,
|
||||
)
|
||||
const stateQuery = useQuery(
|
||||
computed(() => ({
|
||||
...gameSettingsQueryOptions(editorInstanceId.value),
|
||||
enabled: false,
|
||||
})),
|
||||
)
|
||||
const loading = computed(
|
||||
() => !draftState.value && (stateQuery.isPending.value || stateQuery.isFetching.value),
|
||||
)
|
||||
const loadError = computed(() => !draftState.value && stateQuery.isError.value)
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: ({
|
||||
instanceId,
|
||||
request,
|
||||
}: {
|
||||
instanceId?: string
|
||||
request: UpdateGameSettingsRequest
|
||||
}) =>
|
||||
instanceId
|
||||
? preview_local_game_option_changes(instanceId, request)
|
||||
: preview_synced_game_option_changes(request),
|
||||
})
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: saveDraft,
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
function applyState(state: GameSettingsEditorState) {
|
||||
baseState.value = cloneGameSettingsState(state)
|
||||
draftState.value = cloneGameSettingsState(state)
|
||||
touchedValueOptionIds.value = new Set()
|
||||
}
|
||||
|
||||
watch(stateQuery.data, (state) => {
|
||||
if (state && draftState.value && !isDirty.value && !saveMutation.isPending.value) {
|
||||
applyState(state)
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const generation = ++loadGeneration
|
||||
cancelPreview()
|
||||
editorInstanceId.value = toValue(instanceId)
|
||||
const options = gameSettingsQueryOptions(editorInstanceId.value)
|
||||
const cached = !editorInstanceId.value && queryClient.getQueryData(options.queryKey)
|
||||
if (cached) {
|
||||
applyState(cached)
|
||||
try {
|
||||
// A previously cached empty state is common immediately after enabling
|
||||
// sync. Refresh it before returning so the editor does not stay empty.
|
||||
const refreshed = await queryClient.fetchQuery({ ...options, staleTime: 0 })
|
||||
if (generation !== loadGeneration) return false
|
||||
applyState(refreshed)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) return false
|
||||
handleError(error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
const result = await queryClient
|
||||
.fetchQuery({ ...options, staleTime: 0 })
|
||||
.then((data) => ({ data, isError: false, error: null as unknown }))
|
||||
.catch((error) => ({ data: undefined, isError: true, error }))
|
||||
if (generation !== loadGeneration) return false
|
||||
if (result.isError) {
|
||||
handleError(result.error)
|
||||
return false
|
||||
}
|
||||
if (!result.data) return false
|
||||
applyState(result.data)
|
||||
return true
|
||||
}
|
||||
|
||||
function cancelPreview() {
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
previewTimer = null
|
||||
previewGeneration++
|
||||
}
|
||||
|
||||
function reset() {
|
||||
cancelPreview()
|
||||
loadGeneration++
|
||||
saveGeneration++
|
||||
baseState.value = null
|
||||
draftState.value = null
|
||||
touchedValueOptionIds.value = new Set()
|
||||
saveMutation.reset()
|
||||
}
|
||||
|
||||
function cancelChanges() {
|
||||
if (!baseState.value) return
|
||||
cancelPreview()
|
||||
draftState.value = cloneGameSettingsState(baseState.value)
|
||||
touchedValueOptionIds.value = new Set()
|
||||
}
|
||||
|
||||
function setSyncEnabled(optionIds: readonly string[], enabled: boolean) {
|
||||
if (!draftState.value) return
|
||||
const ids = new Set(optionIds)
|
||||
draftState.value.settings = draftState.value.settings.map((setting) =>
|
||||
ids.has(setting.option_id)
|
||||
? { ...setting, sync_enabled: enabled, validation_error: null }
|
||||
: setting,
|
||||
)
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
cancelPreview()
|
||||
previewTimer = setTimeout(() => void preview(), 350)
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (saveMutation.isPending.value || loading.value || hasBlockingDraft.value || !isDirty.value)
|
||||
return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
async function saveAndWait(): Promise<boolean> {
|
||||
if (loading.value || hasBlockingDraft.value || !isDirty.value) return !hasBlockingDraft.value
|
||||
try {
|
||||
await saveMutation.mutateAsync()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function editorRequest(): UpdateGameSettingsRequest | null {
|
||||
if (!baseState.value || !draftState.value) return null
|
||||
return {
|
||||
expected_summary_revision: draftState.value.summary_revision,
|
||||
expected_canonical_revision: draftState.value.canonical_revision,
|
||||
expected_catalog_revision: draftState.value.catalog_revision,
|
||||
changes: dirtyChanges.value,
|
||||
}
|
||||
}
|
||||
|
||||
function setCanonicalValue(optionId: string, value: GameOptionCanonicalValue | null) {
|
||||
if (!draftState.value) return
|
||||
const currentSetting = draftState.value.settings.find(
|
||||
(setting) => setting.option_id === optionId,
|
||||
)
|
||||
const baseSetting = baseState.value?.settings.find((setting) => setting.option_id === optionId)
|
||||
if (!currentSetting) return
|
||||
|
||||
const revertedToBase =
|
||||
!!baseSetting &&
|
||||
!canonicalValuesEqual(currentSetting.canonical_value, baseSetting.canonical_value) &&
|
||||
canonicalValuesEqual(value, baseSetting.canonical_value)
|
||||
const touchedOptionIds = new Set(touchedValueOptionIds.value)
|
||||
if (revertedToBase) {
|
||||
touchedOptionIds.delete(optionId)
|
||||
} else {
|
||||
touchedOptionIds.add(optionId)
|
||||
}
|
||||
touchedValueOptionIds.value = touchedOptionIds
|
||||
draftState.value.settings = draftState.value.settings.map((setting) =>
|
||||
setting.option_id === optionId
|
||||
? {
|
||||
...setting,
|
||||
canonical_value: value,
|
||||
value_state: revertedToBase ? baseSetting.value_state : value ? 'canonical' : 'unset',
|
||||
validation_error: revertedToBase ? baseSetting.validation_error : null,
|
||||
}
|
||||
: setting,
|
||||
)
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function mergePreview(preview: GameSettingsEditorState) {
|
||||
if (!baseState.value || !draftState.value) return
|
||||
const previousBase = cloneGameSettingsState(baseState.value)
|
||||
const previousDraft = cloneGameSettingsState(draftState.value)
|
||||
const dirtyIds = new Set(
|
||||
gameSettingChanges(previousBase, previousDraft, touchedValueOptionIds.value).map(
|
||||
(change) => change.option_id,
|
||||
),
|
||||
)
|
||||
const stagedSettings = new Map(
|
||||
previousDraft.settings
|
||||
.filter((setting) => dirtyIds.has(setting.option_id))
|
||||
.map((setting) => [setting.option_id, setting]),
|
||||
)
|
||||
const previousBaseSettings = new Map(
|
||||
previousBase.settings.map((setting) => [setting.option_id, setting]),
|
||||
)
|
||||
const nextBase = cloneGameSettingsState(preview)
|
||||
const nextDraft = cloneGameSettingsState(preview)
|
||||
nextBase.settings = nextBase.settings.map((setting) =>
|
||||
dirtyIds.has(setting.option_id)
|
||||
? { ...(previousBaseSettings.get(setting.option_id) ?? setting) }
|
||||
: setting,
|
||||
)
|
||||
nextDraft.settings = nextDraft.settings.map((setting): EditableGameSetting => {
|
||||
const staged = stagedSettings.get(setting.option_id)
|
||||
if (!staged) return setting
|
||||
return {
|
||||
...setting,
|
||||
sync_enabled: staged.sync_enabled,
|
||||
canonical_value: staged.canonical_value,
|
||||
option_revision: staged.option_revision,
|
||||
}
|
||||
})
|
||||
baseState.value = nextBase
|
||||
draftState.value = nextDraft
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
const request = editorRequest()
|
||||
if (!request || request.changes.length === 0) {
|
||||
previewGeneration++
|
||||
if (baseState.value) draftState.value = cloneGameSettingsState(baseState.value)
|
||||
return
|
||||
}
|
||||
|
||||
const generation = ++previewGeneration
|
||||
try {
|
||||
const previewState = await previewMutation.mutateAsync({
|
||||
instanceId: editorInstanceId.value,
|
||||
request,
|
||||
})
|
||||
if (generation !== previewGeneration) return
|
||||
mergePreview(previewState)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function applyChangesToRefreshedState(
|
||||
refreshed: GameSettingsEditorState,
|
||||
stagedDraft: GameSettingsEditorState,
|
||||
changes: GameSettingChange[],
|
||||
touchedOptionIds: ReadonlySet<string>,
|
||||
conflictOptionIds: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
const changesById = new Map(changes.map((change) => [change.option_id, change]))
|
||||
const stagedSettings = new Map(
|
||||
stagedDraft.settings.map((setting) => [setting.option_id, setting]),
|
||||
)
|
||||
const nextBase = cloneGameSettingsState(refreshed)
|
||||
const nextDraft = cloneGameSettingsState(refreshed)
|
||||
|
||||
nextDraft.settings = nextDraft.settings.map((setting) => {
|
||||
const change = changesById.get(setting.option_id)
|
||||
const staged = stagedSettings.get(setting.option_id)
|
||||
if (!change || !staged) return setting
|
||||
|
||||
return {
|
||||
...setting,
|
||||
...(change.sync_enabled !== undefined ? { sync_enabled: staged.sync_enabled } : {}),
|
||||
...(change.canonical_value !== undefined
|
||||
? {
|
||||
canonical_value: staged.canonical_value,
|
||||
value_state: staged.value_state,
|
||||
}
|
||||
: {}),
|
||||
validation_error: conflictOptionIds.has(setting.option_id)
|
||||
? 'changed_since_opened'
|
||||
: staged.validation_error,
|
||||
}
|
||||
})
|
||||
|
||||
baseState.value = nextBase
|
||||
draftState.value = nextDraft
|
||||
touchedValueOptionIds.value = new Set(
|
||||
[...touchedOptionIds].filter(
|
||||
(optionId) => changesById.get(optionId)?.canonical_value !== undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
const request = editorRequest()
|
||||
if (!request || request.changes.length === 0 || !baseState.value || !draftState.value) return
|
||||
|
||||
cancelPreview()
|
||||
const targetInstanceId = editorInstanceId.value
|
||||
const generation = ++saveGeneration
|
||||
const previousBase = cloneGameSettingsState(baseState.value)
|
||||
const optimisticState = cloneGameSettingsState(draftState.value)
|
||||
const previouslyTouchedOptionIds = new Set(touchedValueOptionIds.value)
|
||||
baseState.value = cloneGameSettingsState(optimisticState)
|
||||
touchedValueOptionIds.value = new Set()
|
||||
try {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: gameSettingsQueryOptions(targetInstanceId).queryKey,
|
||||
})
|
||||
const result = targetInstanceId
|
||||
? await save_local_game_option_changes(targetInstanceId, request)
|
||||
: await save_synced_game_option_changes(request)
|
||||
const refreshed =
|
||||
result.state ??
|
||||
(targetInstanceId
|
||||
? await get_local_game_options_config(targetInstanceId)
|
||||
: await get_synced_game_options_config())
|
||||
queryClient.setQueryData(gameSettingsQueryOptions(targetInstanceId).queryKey, refreshed)
|
||||
if (targetInstanceId) {
|
||||
void queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
|
||||
}
|
||||
if (result.conflicts?.length) {
|
||||
if (generation !== saveGeneration || !draftState.value) return
|
||||
previewGeneration++
|
||||
const stagedDraft = cloneGameSettingsState(draftState.value)
|
||||
const retainedTouchedOptionIds = new Set([
|
||||
...previouslyTouchedOptionIds,
|
||||
...touchedValueOptionIds.value,
|
||||
])
|
||||
const stagedChanges = gameSettingChanges(
|
||||
previousBase,
|
||||
stagedDraft,
|
||||
retainedTouchedOptionIds,
|
||||
)
|
||||
const conflicts = new Set(result.conflicts)
|
||||
applyChangesToRefreshedState(
|
||||
refreshed,
|
||||
stagedDraft,
|
||||
stagedChanges,
|
||||
retainedTouchedOptionIds,
|
||||
conflicts,
|
||||
)
|
||||
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: formatMessage(messages.conflictTitle),
|
||||
text: formatMessage(messages.conflictText),
|
||||
})
|
||||
if (isDirty.value) {
|
||||
schedulePreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (generation !== saveGeneration) {
|
||||
onSaved()
|
||||
return
|
||||
}
|
||||
if (!baseState.value || !draftState.value) return
|
||||
previewGeneration++
|
||||
const stagedDraft = cloneGameSettingsState(draftState.value)
|
||||
const touchedSinceSave = new Set(touchedValueOptionIds.value)
|
||||
const changesSinceSave = gameSettingChanges(optimisticState, stagedDraft, touchedSinceSave)
|
||||
applyChangesToRefreshedState(refreshed, stagedDraft, changesSinceSave, touchedSinceSave)
|
||||
if (changesSinceSave.length > 0) schedulePreview()
|
||||
onSaved()
|
||||
} catch (error) {
|
||||
if (generation === saveGeneration && draftState.value) {
|
||||
previewGeneration++
|
||||
baseState.value = previousBase
|
||||
touchedValueOptionIds.value = new Set([
|
||||
...previouslyTouchedOptionIds,
|
||||
...touchedValueOptionIds.value,
|
||||
])
|
||||
if (dirtyChanges.value.length > 0) schedulePreview()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(reset)
|
||||
|
||||
return {
|
||||
draftState,
|
||||
isLocalEditor,
|
||||
isDirty,
|
||||
hasBlockingDraft,
|
||||
loading,
|
||||
loadError,
|
||||
saving: saveMutation.isPending,
|
||||
load,
|
||||
reset,
|
||||
cancelChanges,
|
||||
setSyncEnabled,
|
||||
setCanonicalValue,
|
||||
save,
|
||||
saveAndWait,
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { computed, type MaybeRefOrGetter, onScopeDispose, shallowRef, toValue, watch } from 'vue'
|
||||
|
||||
import {
|
||||
type EditableGameSetting,
|
||||
type GameSettingLocaleLabel,
|
||||
get_game_setting_locale_labels,
|
||||
} from '@/helpers/game-options'
|
||||
|
||||
export function useGameSettingLabels(
|
||||
opened: MaybeRefOrGetter<boolean>,
|
||||
instanceId: MaybeRefOrGetter<string | undefined>,
|
||||
settings: MaybeRefOrGetter<EditableGameSetting[]>,
|
||||
) {
|
||||
const debug = useDebugLogger('GameSettingLabels')
|
||||
const { locale } = useVIntl()
|
||||
const labels = shallowRef<Record<string, GameSettingLocaleLabel>>({})
|
||||
const optionIds = computed(() =>
|
||||
toValue(settings)
|
||||
.filter((setting) => setting.kind === 'external')
|
||||
.map((setting) => setting.option_id)
|
||||
.sort(),
|
||||
)
|
||||
let generation = 0
|
||||
let stopListening: UnlistenFn | undefined
|
||||
let refreshSources = false
|
||||
|
||||
debug('setup', { opened: toValue(opened), instanceId: toValue(instanceId), locale: locale.value })
|
||||
|
||||
async function refresh(reason: string) {
|
||||
if (!toValue(opened) || !optionIds.value.length) {
|
||||
debug('refresh skipped', {
|
||||
reason,
|
||||
opened: toValue(opened),
|
||||
optionCount: optionIds.value.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
const request = ++generation
|
||||
const reindex = refreshSources
|
||||
const requestedIds = [...optionIds.value]
|
||||
const started = performance.now()
|
||||
refreshSources = false
|
||||
debug('request started', {
|
||||
request,
|
||||
reason,
|
||||
instanceId: toValue(instanceId),
|
||||
locale: locale.value,
|
||||
reindex,
|
||||
optionIds: requestedIds,
|
||||
})
|
||||
try {
|
||||
const result = await get_game_setting_locale_labels(
|
||||
toValue(instanceId),
|
||||
locale.value,
|
||||
requestedIds,
|
||||
reindex,
|
||||
)
|
||||
const applied = request === generation && toValue(opened)
|
||||
debug('request completed', {
|
||||
request,
|
||||
generation,
|
||||
applied,
|
||||
opened: toValue(opened),
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
returnedCount: Object.keys(result.settings).length,
|
||||
missingOptionIds: requestedIds.filter((id) => !result.settings[id]),
|
||||
labelSample: Object.entries(result.settings).slice(0, 8),
|
||||
})
|
||||
if (applied) labels.value = result.settings
|
||||
} catch (error) {
|
||||
debug('request failed', {
|
||||
request,
|
||||
reason,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(opened),
|
||||
(active, _, onCleanup) => {
|
||||
debug('modal state changed', { active, generation, optionCount: optionIds.value.length })
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
stopListening?.()
|
||||
stopListening = undefined
|
||||
generation++
|
||||
labels.value = {}
|
||||
debug('modal cleanup: listener removed and labels cleared', { generation })
|
||||
})
|
||||
if (!active) return
|
||||
refreshSources = true
|
||||
debug('registering locale update listener')
|
||||
void listen('game-option-locales-updated', () => {
|
||||
debug('received game-option-locales-updated')
|
||||
void refresh('locale update event')
|
||||
})
|
||||
.then((unlisten) => {
|
||||
debug('locale update listener registered', { cancelled })
|
||||
if (cancelled) unlisten()
|
||||
else {
|
||||
stopListening = unlisten
|
||||
void refresh('listener registered')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
debug('locale update listener failed', { cancelled, error })
|
||||
if (!cancelled) void refresh('listener failed')
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch([locale, () => toValue(instanceId), () => optionIds.value.join('\n')], () => {
|
||||
generation++
|
||||
labels.value = {}
|
||||
debug('locale, instance or option IDs changed: labels cleared', {
|
||||
generation,
|
||||
locale: locale.value,
|
||||
instanceId: toValue(instanceId),
|
||||
optionCount: optionIds.value.length,
|
||||
})
|
||||
void refresh('request inputs changed')
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
generation++
|
||||
stopListening?.()
|
||||
labels.value = {}
|
||||
debug('scope disposed', { generation })
|
||||
})
|
||||
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
MoreVerticalIcon,
|
||||
NoSignalIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SignalIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
type ButtonMenuOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilterPills,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Table,
|
||||
type TableColumn,
|
||||
TeleportOverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { remove_synced_server, type SyncedServer, update_synced_server } from '@/helpers/instance'
|
||||
import { syncedOptionsKeys, syncedServersQueryOptions } from '@/helpers/synced-options'
|
||||
import { copyToClipboard } from '@/helpers/utils'
|
||||
import {
|
||||
refreshServerData,
|
||||
refreshServers,
|
||||
type ServerData,
|
||||
type ServerWorld,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const editedServer = ref<SyncedServer | null>(null)
|
||||
const serversQuery = useQuery(syncedServersQueryOptions())
|
||||
const syncedServers = computed(() => serversQuery.data.value ?? [])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const serverSearch = ref('')
|
||||
const selectedServerFilters = ref<string[]>([])
|
||||
|
||||
const messages = defineMessages({
|
||||
serverEditorTitle: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
|
||||
defaultMessage: 'Edit synced servers',
|
||||
},
|
||||
editServerTitle: {
|
||||
id: 'instance.edit-server.title',
|
||||
defaultMessage: 'Edit server',
|
||||
},
|
||||
serverName: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.name',
|
||||
defaultMessage: 'Server name',
|
||||
},
|
||||
serverAddress: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.address',
|
||||
defaultMessage: 'Server address',
|
||||
},
|
||||
searchServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.search',
|
||||
defaultMessage:
|
||||
'Search {count, number} {count, plural, one {synced server} other {synced servers}}...',
|
||||
},
|
||||
serverStatus: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.status',
|
||||
defaultMessage: 'Status',
|
||||
},
|
||||
onlineFilter: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.filter-online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offlineFilter: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.filter-offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
playersOnline: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.players-online',
|
||||
defaultMessage: '{count, number} online',
|
||||
},
|
||||
noMatchingServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.no-results',
|
||||
defaultMessage: 'No synced servers match your search or filters.',
|
||||
},
|
||||
serverCount: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.count',
|
||||
defaultMessage: '{count, plural, one {# server} other {# servers}}',
|
||||
},
|
||||
moreServerOptions: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.more-options',
|
||||
defaultMessage: 'More server options',
|
||||
},
|
||||
copyServerAddress: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.copy-address',
|
||||
defaultMessage: 'Copy address',
|
||||
},
|
||||
noSyncedServers: {
|
||||
id: 'app.settings.synced-options.multiplayer-servers.empty',
|
||||
defaultMessage: 'No user-added servers are currently synced.',
|
||||
},
|
||||
})
|
||||
|
||||
const serverWorlds = computed<ServerWorld[]>(() =>
|
||||
syncedServers.value.map((server, index) => ({
|
||||
name: server.name,
|
||||
type: 'server',
|
||||
index,
|
||||
server_id: server.id,
|
||||
address: server.address,
|
||||
pack_status:
|
||||
server.accept_textures === true
|
||||
? 'enabled'
|
||||
: server.accept_textures === false
|
||||
? 'disabled'
|
||||
: 'prompt',
|
||||
display_status: 'normal',
|
||||
})),
|
||||
)
|
||||
|
||||
type SyncedServerTableColumn = 'server' | 'status' | 'version' | 'actions'
|
||||
type SyncedServerTableRow = SyncedServer & {
|
||||
server: string
|
||||
status: 'online' | 'offline'
|
||||
version: string
|
||||
actions: null
|
||||
}
|
||||
|
||||
const syncedServerColumns = computed<TableColumn<SyncedServerTableColumn>[]>(() => [
|
||||
{
|
||||
key: 'server',
|
||||
label: formatMessage(commonMessages.serverLabel),
|
||||
width: '45%',
|
||||
cellClass: '!h-16',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: formatMessage(messages.serverStatus),
|
||||
width: '25%',
|
||||
cellClass: '!h-16',
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: formatMessage(commonMessages.versionLabel),
|
||||
width: '22%',
|
||||
cellClass: '!h-16',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: formatMessage(commonMessages.actionsLabel),
|
||||
align: 'right',
|
||||
width: '6rem',
|
||||
cellClass: '!h-16',
|
||||
},
|
||||
])
|
||||
|
||||
const syncedServerFilterOptions = computed(() => [
|
||||
{ id: 'online', label: formatMessage(messages.onlineFilter) },
|
||||
{ id: 'offline', label: formatMessage(messages.offlineFilter) },
|
||||
])
|
||||
|
||||
const syncedServerRows = computed<SyncedServerTableRow[]>(() =>
|
||||
syncedServers.value.map((server) => ({
|
||||
...server,
|
||||
server: server.name,
|
||||
status: serverData.value[server.address]?.status ? 'online' : 'offline',
|
||||
version: serverData.value[server.address]?.status?.version?.name ?? '',
|
||||
actions: null,
|
||||
})),
|
||||
)
|
||||
|
||||
const statusFilteredSyncedServerRows = computed(() => {
|
||||
if (selectedServerFilters.value.length === 0) return syncedServerRows.value
|
||||
return syncedServerRows.value.filter((server) =>
|
||||
selectedServerFilters.value.includes(server.status),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredSyncedServerRows = computed(() => {
|
||||
const search = serverSearch.value.trim().toLocaleLowerCase()
|
||||
if (!search) return statusFilteredSyncedServerRows.value
|
||||
return statusFilteredSyncedServerRows.value.filter(
|
||||
(server) =>
|
||||
server.name.toLocaleLowerCase().includes(search) ||
|
||||
server.address.toLocaleLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: () => refreshServers(serverWorlds.value, serverData.value, null),
|
||||
onError: handleError,
|
||||
})
|
||||
const refreshingAllSyncedServers = refreshMutation.isPending
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: update_synced_server,
|
||||
onSuccess: async (_, server) => {
|
||||
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
|
||||
servers?.map((current) => (current.id === server.id ? { ...server } : current)),
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||
void refreshSyncedServer(server.address).catch(handleError)
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: remove_synced_server,
|
||||
onSuccess: async (_, serverId) => {
|
||||
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
|
||||
servers?.filter((server) => server.id !== serverId),
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
async function show() {
|
||||
const result = await serversQuery.refetch()
|
||||
if (result.isError) {
|
||||
handleError(result.error)
|
||||
return
|
||||
}
|
||||
serverSearch.value = ''
|
||||
selectedServerFilters.value = []
|
||||
serverData.value = {}
|
||||
serverEditorModal.value?.show()
|
||||
refreshAllSyncedServers()
|
||||
}
|
||||
|
||||
function openSyncedServerEditor(server: SyncedServer) {
|
||||
editedServer.value = { ...server }
|
||||
editServerModal.value?.show()
|
||||
}
|
||||
|
||||
async function saveSyncedServer() {
|
||||
if (!editedServer.value || saveMutation.isPending.value) return
|
||||
try {
|
||||
await saveMutation.mutateAsync({ ...editedServer.value })
|
||||
await nextTick()
|
||||
editServerModal.value?.hide()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSyncedServer(address: string) {
|
||||
serverData.value[address] ??= { refreshing: true }
|
||||
await refreshServerData(serverData.value[address], null, address)
|
||||
}
|
||||
|
||||
function refreshAllSyncedServers() {
|
||||
if (!refreshingAllSyncedServers.value) refreshMutation.mutate()
|
||||
}
|
||||
|
||||
function syncedServerMenuOptions(server: SyncedServer): ButtonMenuOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'refresh',
|
||||
label: formatMessage(commonMessages.refreshButton),
|
||||
icon: RefreshCwIcon,
|
||||
action: () => refreshSyncedServer(server.address).catch(handleError),
|
||||
},
|
||||
{
|
||||
id: 'copy-address',
|
||||
label: formatMessage(messages.copyServerAddress),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => copyToClipboard(server.address),
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
label: formatMessage(commonMessages.editButton),
|
||||
icon: EditIcon,
|
||||
action: () => openSyncedServerEditor(server),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
id: 'remove',
|
||||
label: formatMessage(commonMessages.removeButton),
|
||||
icon: TrashIcon,
|
||||
tone: 'red',
|
||||
disabled: removeMutation.isPending.value,
|
||||
action: () => removeMutation.mutate(server.id),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="serverEditorModal"
|
||||
:header="formatMessage(messages.serverEditorTitle)"
|
||||
no-padding
|
||||
max-width="min(928px, calc(100vw - 2rem))"
|
||||
width="min(928px, calc(100vw - 2rem))"
|
||||
>
|
||||
<div class="flex max-h-[min(600px,calc(95vh-10rem))] flex-col">
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-4 border-0 border-b border-solid border-surface-4 px-6 py-4"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="serverSearch"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:placeholder="formatMessage(messages.searchServers, { count: syncedServers.length })"
|
||||
size="medium"
|
||||
:aria-label="formatMessage(messages.searchServers, { count: syncedServers.length })"
|
||||
:clear-label="formatMessage(commonMessages.clearButton)"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<FilterPills v-model="selectedServerFilters" :options="syncedServerFilterOptions">
|
||||
<template #all>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</template>
|
||||
</FilterPills>
|
||||
<Button
|
||||
type="quiet"
|
||||
:disabled="refreshingAllSyncedServers || syncedServers.length === 0"
|
||||
interaction="none"
|
||||
@click="refreshAllSyncedServers"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
:class="{ 'animate-spin': refreshingAllSyncedServers }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="syncedServers.length === 0"
|
||||
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.noSyncedServers) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredSyncedServerRows.length === 0"
|
||||
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.noMatchingServers) }}
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto">
|
||||
<Table
|
||||
:columns="syncedServerColumns"
|
||||
:data="filteredSyncedServerRows"
|
||||
row-key="id"
|
||||
table-min-width="44rem"
|
||||
class="!rounded-none !border-0"
|
||||
>
|
||||
<template #cell-server="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="serverData[row.address]?.status?.favicon"
|
||||
:alt="row.name"
|
||||
:tint-by="row.address"
|
||||
size="32px"
|
||||
no-shadow
|
||||
class="shrink-0 !rounded-lg"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
|
||||
row.name
|
||||
}}</span>
|
||||
<span class="truncate text-sm text-[var(--color-text-tertiary)]">{{
|
||||
row.address
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<span class="inline-flex items-center gap-1.5 font-medium">
|
||||
<RefreshCwIcon
|
||||
v-if="serverData[row.address]?.refreshing"
|
||||
class="size-4 shrink-0 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SignalIcon
|
||||
v-else-if="serverData[row.address]?.status"
|
||||
class="size-4 shrink-0 text-green"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<NoSignalIcon v-else class="size-4 shrink-0" aria-hidden="true" />
|
||||
<template v-if="serverData[row.address]?.refreshing">
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverData[row.address]?.status">
|
||||
{{
|
||||
formatMessage(messages.playersOnline, {
|
||||
count: serverData[row.address]?.status?.players?.online ?? 0,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(messages.offlineFilter) }}</template>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-version="{ row }">
|
||||
<span class="block truncate">{{ row.version || '—' }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end">
|
||||
<TeleportOverflowMenu
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.moreServerOptions)"
|
||||
:options="syncedServerMenuOptions(row)"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center border-0 border-t border-solid border-surface-4 px-6 py-4"
|
||||
>
|
||||
<span class="font-medium text-[var(--color-text-default)]">
|
||||
{{ formatMessage(messages.serverCount, { count: syncedServers.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="editServerModal"
|
||||
:header="formatMessage(messages.editServerTitle)"
|
||||
:disable-close="saveMutation.isPending.value"
|
||||
max-width="500px"
|
||||
width="500px"
|
||||
>
|
||||
<div v-if="editedServer" class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.serverName) }}
|
||||
<StyledInput v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.serverAddress) }}
|
||||
<StyledInput v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="editServerModal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="!editedServer?.address"
|
||||
:loading="saveMutation.isPending.value"
|
||||
@click="saveSyncedServer"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import type { MessageDescriptor } from '@modrinth/ui'
|
||||
export type SettingsCategoryId =
|
||||
| 'interface'
|
||||
| 'home-navigation'
|
||||
| 'shortcut-settings'
|
||||
| 'language-translation'
|
||||
| 'ai'
|
||||
| 'java-performance'
|
||||
@@ -13,6 +14,7 @@ export type SettingsCategoryId =
|
||||
| 'updates'
|
||||
| 'about'
|
||||
| 'feature-flags'
|
||||
| 'logs'
|
||||
|
||||
export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer'
|
||||
|
||||
@@ -44,6 +46,15 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||
group: 'launcher',
|
||||
onboardingId: 'settings-tab-home-navigation',
|
||||
},
|
||||
{
|
||||
id: 'shortcut-settings',
|
||||
name: {
|
||||
id: 'app.settings.tabs.shortcut-settings',
|
||||
defaultMessage: 'Keyboard shortcuts',
|
||||
},
|
||||
group: 'launcher',
|
||||
onboardingId: 'settings-tab-shortcut-settings',
|
||||
},
|
||||
{
|
||||
id: 'language-translation',
|
||||
name: {
|
||||
@@ -116,6 +127,11 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
|
||||
group: 'support',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: { id: 'app.settings.tabs.logs', defaultMessage: 'Logs' },
|
||||
group: 'support',
|
||||
},
|
||||
{
|
||||
id: 'feature-flags',
|
||||
name: {
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
BotIcon,
|
||||
CoffeeIcon,
|
||||
CpuIcon,
|
||||
FileTextIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
LanguagesIcon,
|
||||
LayoutTemplateIcon,
|
||||
PaintbrushIcon,
|
||||
@@ -48,6 +50,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
||||
icon: LayoutTemplateIcon,
|
||||
content: defineAsyncComponent(() => import('./HomeNavigationSettings.vue')),
|
||||
},
|
||||
'shortcut-settings': {
|
||||
icon: KeyboardIcon,
|
||||
content: defineAsyncComponent(() => import('./KeybindSettings.vue')),
|
||||
},
|
||||
'language-translation': {
|
||||
icon: LanguagesIcon,
|
||||
content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')),
|
||||
@@ -78,6 +84,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
||||
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
||||
},
|
||||
about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) },
|
||||
logs: {
|
||||
icon: FileTextIcon,
|
||||
content: defineAsyncComponent(() => import('./LogsSettings.vue')),
|
||||
},
|
||||
'feature-flags': {
|
||||
icon: ToggleRightIcon,
|
||||
content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')),
|
||||
|
||||
@@ -384,6 +384,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
||||
categoryId: 'feature-flags',
|
||||
label: message('settings.feature-flags.title', 'Feature flags'),
|
||||
},
|
||||
{
|
||||
id: 'logs-export',
|
||||
categoryId: 'logs',
|
||||
targetId: 'settings-target-logs-export',
|
||||
label: message('app.settings.logs.export.title', 'Export logs'),
|
||||
description: message(
|
||||
'app.settings.logs.export.description',
|
||||
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type ArmorPreviewConfig,
|
||||
ArmorTrimPanel,
|
||||
Button,
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skins.armor.title', defaultMessage: 'Armour and trims' },
|
||||
description: {
|
||||
id: 'app.skins.armor.description',
|
||||
defaultMessage: 'Try armour and trims on your skin. Nothing is stored until you save it.',
|
||||
},
|
||||
unsaved: { id: 'app.skins.armor.unsaved', defaultMessage: 'Unsaved changes' },
|
||||
resetToSaved: { id: 'app.skins.armor.reset-to-saved', defaultMessage: 'Reset to saved' },
|
||||
restoreDefaults: {
|
||||
id: 'app.skins.armor.restore-defaults',
|
||||
defaultMessage: 'Restore defaults',
|
||||
},
|
||||
save: { id: 'app.skins.armor.save', defaultMessage: 'Save' },
|
||||
close: { id: 'app.skins.armor.close', defaultMessage: 'Close' },
|
||||
})
|
||||
|
||||
const draft = defineModel<ArmorPreviewConfig>({ required: true })
|
||||
const props = defineProps<{
|
||||
saved: ArmorPreviewConfig
|
||||
/** Stable id shared with the toggle so it can point `aria-controls` here. */
|
||||
panelId: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
reset: []
|
||||
defaults: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
/**
|
||||
* The tab is mounted only while it is showing, so this listens exactly for that
|
||||
* window. It listens in the capture phase because the app's own global
|
||||
* shortcuts listen on `window` the same way, and a bubble-phase listener here
|
||||
* would be skipped as soon as anything closer stopped the event. A modal on top
|
||||
* of the page owns Escape while it is up, and focus sits inside it, so the key
|
||||
* is left to it.
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown, true))
|
||||
onUnmounted(() => window.removeEventListener('keydown', handleKeydown, true))
|
||||
|
||||
const isDirty = computed(() =>
|
||||
Object.entries(draft.value).some(([slot, piece]) => {
|
||||
const applied = props.saved[slot as keyof ArmorPreviewConfig]
|
||||
return (
|
||||
!applied ||
|
||||
applied.material !== piece.material ||
|
||||
applied.trimPattern !== piece.trimPattern ||
|
||||
applied.trimMaterial !== piece.trimMaterial
|
||||
)
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="panelId"
|
||||
role="region"
|
||||
:aria-label="formatMessage(messages.title)"
|
||||
class="flex min-h-[24rem] flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||
>
|
||||
<header class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="m-0 text-lg font-bold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-6 text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<span v-if="isDirty" class="text-sm font-semibold text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.unsaved) }}
|
||||
</span>
|
||||
<Button type="outlined" @click="emit('close')"
|
||||
><XIcon />{{ formatMessage(messages.close) }}</Button
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ArmorTrimPanel v-model="draft" />
|
||||
|
||||
<footer
|
||||
class="mt-auto flex flex-wrap items-center justify-between gap-2 border-0 border-t border-solid border-surface-5 pt-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="outlined" :disabled="!isDirty" @click="emit('reset')"
|
||||
><RotateCounterClockwiseIcon />{{ formatMessage(messages.resetToSaved) }}</Button
|
||||
>
|
||||
<Button type="outlined" @click="emit('defaults')">{{
|
||||
formatMessage(messages.restoreDefaults)
|
||||
}}</Button>
|
||||
</div>
|
||||
<Button type="colored" color="brand" :disabled="!isDirty" @click="emit('save')"
|
||||
><CheckIcon />{{ formatMessage(messages.save) }}</Button
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
const { sections = 2, cards = 12 } = defineProps<{
|
||||
sections?: number
|
||||
cards?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Shaped after VirtualSkinSectionList: the same grid breakpoints and card
|
||||
ratio, so the swap to real content does not move anything. Decorative, so
|
||||
it stays out of the accessibility tree.
|
||||
-->
|
||||
<div class="flex flex-col gap-8" aria-hidden="true">
|
||||
<div v-for="section in sections" :key="section" class="flex flex-col gap-4">
|
||||
<div class="h-7 w-44 animate-pulse rounded-lg bg-surface-4" />
|
||||
<div
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card"
|
||||
class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -605,3 +605,110 @@ test('skin updates are restricted to known non-Mojang players and validated resu
|
||||
setSkinSiteFrame(null)
|
||||
resetSkinSiteSession()
|
||||
})
|
||||
|
||||
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
|
||||
let accounts: Array<{
|
||||
account_type: string
|
||||
yggdrasil?: { api_root: string }
|
||||
}> = []
|
||||
const frame = {
|
||||
postMessage(data: { type: string; requestId: string }) {
|
||||
if (data.type === 'starlight-pack-token-request') {
|
||||
receiveSkinSiteMessage(
|
||||
{
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: {
|
||||
type: 'starlight-pack-token-result',
|
||||
requestId: data.requestId,
|
||||
token: 'site.jwt.secret',
|
||||
},
|
||||
} as MessageEvent,
|
||||
frame,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (data.type === 'starlight-skin-players-request') {
|
||||
receiveSkinSiteMessage(
|
||||
{
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: {
|
||||
type: 'starlight-skin-players-result',
|
||||
requestId: data.requestId,
|
||||
ok: true,
|
||||
players: [
|
||||
{
|
||||
uuid: '11111111-1111-1111-1111-111111111111',
|
||||
name: 'AutoPlayer',
|
||||
isMojang: false,
|
||||
skinState: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as MessageEvent,
|
||||
frame,
|
||||
)
|
||||
}
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__TAURI_INTERNALS__: {
|
||||
async invoke(command: string, args: Record<string, unknown>) {
|
||||
calls.push({ command, args })
|
||||
if (command === 'plugin:install|hosted_has_game_account') {
|
||||
return accounts.some(
|
||||
(account) =>
|
||||
account.account_type === 'microsoft' ||
|
||||
(account.account_type === 'yggdrasil' &&
|
||||
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
|
||||
'https://skin.starlight.cool/yggdrasil'),
|
||||
)
|
||||
}
|
||||
if (command === 'plugin:auth|login_skin_site_player') {
|
||||
accounts = [
|
||||
{
|
||||
account_type: 'yggdrasil',
|
||||
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
|
||||
},
|
||||
]
|
||||
return null
|
||||
}
|
||||
if (command === 'plugin:instance|instance_get') return null
|
||||
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
try {
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(frame)
|
||||
receiveSkinSiteMessage(
|
||||
{
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: {
|
||||
type: 'starlight-skin-session',
|
||||
status: 'signed-in',
|
||||
user: { uuid: 'site-user', username: 'Site' },
|
||||
},
|
||||
} as MessageEvent,
|
||||
frame,
|
||||
)
|
||||
await requestSkinSitePlayers().catch(() => {})
|
||||
assert.equal(await hostedCreate(), 'auto-instance')
|
||||
assert.ok(
|
||||
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
|
||||
'expected automatic player authentication',
|
||||
)
|
||||
} finally {
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(null)
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else delete (globalThis as { window?: unknown }).window
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
|
||||
export interface InstanceAppEvent {
|
||||
event: string
|
||||
instance_id?: string
|
||||
}
|
||||
|
||||
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
|
||||
if (type !== 'instance') return
|
||||
let unlisten: (() => void) | undefined
|
||||
void instance_listener(callback).then((dispose) => {
|
||||
unlisten = dispose
|
||||
})
|
||||
onUnmounted(() => unlisten?.())
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
|
||||
|
||||
export type ImageThumbnailSource = {
|
||||
instanceId: string
|
||||
filePath: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type ThumbnailCacheEntry = {
|
||||
promise: Promise<string>
|
||||
url?: string
|
||||
users: number
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_THUMBNAILS = 4
|
||||
const MAX_CACHED_THUMBNAILS = 256
|
||||
|
||||
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
|
||||
const thumbnailQueue: Array<() => void> = []
|
||||
let activeThumbnailRequests = 0
|
||||
|
||||
function runThumbnailQueue() {
|
||||
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
|
||||
activeThumbnailRequests += 1
|
||||
thumbnailQueue.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueThumbnail<T>(task: () => Promise<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
thumbnailQueue.push(() => {
|
||||
void task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeThumbnailRequests -= 1
|
||||
runThumbnailQueue()
|
||||
})
|
||||
})
|
||||
runThumbnailQueue()
|
||||
})
|
||||
}
|
||||
|
||||
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
|
||||
if (thumbnailCache.get(cacheKey) !== entry) return
|
||||
thumbnailCache.delete(cacheKey)
|
||||
if (entry.url) {
|
||||
URL.revokeObjectURL(entry.url)
|
||||
} else {
|
||||
void entry.promise.then(
|
||||
(url) => URL.revokeObjectURL(url),
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function trimThumbnailCache() {
|
||||
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
|
||||
|
||||
const unused = [...thumbnailCache.entries()]
|
||||
.filter(([, entry]) => entry.users === 0)
|
||||
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
|
||||
for (const [cacheKey, entry] of unused) {
|
||||
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
|
||||
removeThumbnail(cacheKey, entry)
|
||||
}
|
||||
}
|
||||
|
||||
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
|
||||
const cacheKey = JSON.stringify([
|
||||
source.instanceId,
|
||||
source.filePath,
|
||||
maxDimension,
|
||||
revision ?? null,
|
||||
])
|
||||
let entry = thumbnailCache.get(cacheKey)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
users: 0,
|
||||
lastUsed: performance.now(),
|
||||
promise: enqueueThumbnail(async () => {
|
||||
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
|
||||
instanceId: source.instanceId,
|
||||
filePath: source.filePath,
|
||||
maxDimension,
|
||||
})
|
||||
return URL.createObjectURL(new Blob([bytes]))
|
||||
}),
|
||||
}
|
||||
thumbnailCache.set(cacheKey, entry)
|
||||
void entry.promise.then(
|
||||
(url) => {
|
||||
entry!.url = url
|
||||
trimThumbnailCache()
|
||||
},
|
||||
() => {
|
||||
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry.users += 1
|
||||
entry.lastUsed = performance.now()
|
||||
return {
|
||||
promise: entry.promise,
|
||||
release: () => {
|
||||
entry!.users = Math.max(0, entry!.users - 1)
|
||||
entry!.lastUsed = performance.now()
|
||||
trimThumbnailCache()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useImageThumbnail(
|
||||
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
|
||||
maxDimension: MaybeRefOrGetter<number> = 512,
|
||||
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
|
||||
) {
|
||||
const thumbnail = ref('')
|
||||
|
||||
watch(
|
||||
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
|
||||
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
|
||||
thumbnail.value = ''
|
||||
if (!currentSource) return
|
||||
|
||||
let active = true
|
||||
const fallbackUrl = convertFileSrc(currentSource.path)
|
||||
const acquired = acquireThumbnail(
|
||||
currentSource,
|
||||
Math.max(1, Math.round(currentMaxDimension)),
|
||||
currentRevision,
|
||||
)
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
acquired.release()
|
||||
})
|
||||
|
||||
void acquired.promise.then(
|
||||
(url) => {
|
||||
if (active) thumbnail.value = url
|
||||
},
|
||||
(error) => {
|
||||
console.warn('Could not create image thumbnail', error)
|
||||
if (active) thumbnail.value = fallbackUrl
|
||||
},
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return thumbnail
|
||||
}
|
||||
@@ -7,15 +7,22 @@ export const UNGROUPED_GROUP_KEY = '__ungrouped__'
|
||||
|
||||
export type GridGroupingOption = 'Group' | 'Loader' | 'Game version' | 'None'
|
||||
export type GridSortOption =
|
||||
| 'Name'
|
||||
| 'Last played'
|
||||
| 'Date created'
|
||||
| 'Date modified'
|
||||
| 'Game version'
|
||||
'Name' | 'Last played' | 'Date created' | 'Date modified' | 'Game version'
|
||||
|
||||
export type GridSortDirection = 'asc' | 'desc'
|
||||
|
||||
export const defaultSortDirections: Record<GridSortOption, GridSortDirection> = {
|
||||
Name: 'asc',
|
||||
'Last played': 'desc',
|
||||
'Date created': 'desc',
|
||||
'Date modified': 'desc',
|
||||
'Game version': 'asc',
|
||||
}
|
||||
|
||||
export interface GridDisplayState {
|
||||
group: GridGroupingOption
|
||||
sortBy: GridSortOption
|
||||
sortDirections: Partial<Record<GridSortOption, GridSortDirection>>
|
||||
collapsedGroups: string[]
|
||||
}
|
||||
|
||||
@@ -38,6 +45,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
{
|
||||
group: 'Group',
|
||||
sortBy: 'Name',
|
||||
sortDirections: {},
|
||||
collapsedGroups: [],
|
||||
},
|
||||
localStorage,
|
||||
@@ -66,7 +74,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
state.value.collapsedGroups = [...collapsedSections]
|
||||
}
|
||||
|
||||
function sortInstances(instances: T[], sortBy: GridSortOption): T[] {
|
||||
function sortInstances(instances: T[], sortBy: GridSortOption, ascending: boolean): T[] {
|
||||
const sorted = [...instances]
|
||||
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
|
||||
const getLastPlayed = options.getLastPlayed ?? ((i: T) => i.last_played ?? 0)
|
||||
@@ -83,16 +91,19 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
)
|
||||
break
|
||||
case 'Last played':
|
||||
sorted.sort((a, b) => dayjs(getLastPlayed(b)).diff(dayjs(getLastPlayed(a))))
|
||||
sorted.sort((a, b) => dayjs(getLastPlayed(a)).diff(dayjs(getLastPlayed(b))))
|
||||
break
|
||||
case 'Date created':
|
||||
sorted.sort((a, b) => dayjs(getDateCreated(b)).diff(dayjs(getDateCreated(a))))
|
||||
sorted.sort((a, b) => dayjs(getDateCreated(a)).diff(dayjs(getDateCreated(b))))
|
||||
break
|
||||
case 'Date modified':
|
||||
sorted.sort((a, b) => dayjs(getDateModified(b)).diff(dayjs(getDateModified(a))))
|
||||
sorted.sort((a, b) => dayjs(getDateModified(a)).diff(dayjs(getDateModified(b))))
|
||||
break
|
||||
}
|
||||
|
||||
if (!ascending) {
|
||||
sorted.reverse()
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
@@ -148,11 +159,25 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
function sortSections(
|
||||
instanceMap: Map<string, T[]>,
|
||||
group: GridGroupingOption,
|
||||
sortBy: GridSortOption,
|
||||
): Map<string, T[]> {
|
||||
if (group !== 'Group' && group !== 'Game version') {
|
||||
return instanceMap
|
||||
}
|
||||
|
||||
if (group === 'Group' && sortBy !== 'Name') {
|
||||
if (instanceMap.has(UNGROUPED_GROUP_KEY) && instanceMap.size > 1) {
|
||||
const ungroupedInstances = instanceMap.get(UNGROUPED_GROUP_KEY)!
|
||||
instanceMap.delete(UNGROUPED_GROUP_KEY)
|
||||
const newMap = new Map<string, T[]>([[UNGROUPED_GROUP_KEY, ungroupedInstances]])
|
||||
for (const [key, instances] of instanceMap) {
|
||||
newMap.set(key, instances)
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
return instanceMap
|
||||
}
|
||||
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
if (group === 'Group') {
|
||||
if (a[0] === UNGROUPED_GROUP_KEY && b[0] !== UNGROUPED_GROUP_KEY) return -1
|
||||
@@ -166,12 +191,30 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
return instanceMap
|
||||
}
|
||||
|
||||
const sortDirection = computed<GridSortDirection>(() => {
|
||||
return (
|
||||
state.value.sortDirections[state.value.sortBy] ?? defaultSortDirections[state.value.sortBy]
|
||||
)
|
||||
})
|
||||
|
||||
const isSortAscending = computed(() => sortDirection.value === 'asc')
|
||||
|
||||
const toggleSortDirection = () => {
|
||||
const current = sortDirection.value
|
||||
const next: GridSortDirection = current === 'asc' ? 'desc' : 'asc'
|
||||
state.value.sortDirections = {
|
||||
...state.value.sortDirections,
|
||||
[state.value.sortBy]: next,
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
const { group = 'Group', sortBy = 'Name' } = state.value
|
||||
const ascending = isSortAscending.value
|
||||
|
||||
const sorted = sortInstances(instances.value, sortBy)
|
||||
const sorted = sortInstances(instances.value, sortBy, ascending)
|
||||
const grouped = groupInstances(sorted, group)
|
||||
return sortSections(grouped, group)
|
||||
return sortSections(grouped, group, sortBy)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -180,6 +223,9 @@ export function useGridGrouping<T extends Record<string, unknown>>(
|
||||
filteredResults,
|
||||
isSectionCollapsed,
|
||||
setSectionCollapsed,
|
||||
sortDirection,
|
||||
isSortAscending,
|
||||
toggleSortDirection,
|
||||
UNGROUPED_GROUP_KEY,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { instance_groups_listener } from '@/helpers/events'
|
||||
import {
|
||||
create_group,
|
||||
delete_group,
|
||||
type InstanceGroupDefinition,
|
||||
type InstanceGroupMembershipUpdate,
|
||||
list_groups,
|
||||
MAX_INSTANCE_GROUP_NAME_LENGTH,
|
||||
rename_group,
|
||||
set_group_memberships,
|
||||
set_group_order,
|
||||
} from '@/helpers/instance-groups'
|
||||
|
||||
export const FAVORITES_GROUP_ID = 'group:favorites'
|
||||
|
||||
export const UNGROUPED_GROUP_ID = 'group:none'
|
||||
|
||||
export { MAX_INSTANCE_GROUP_NAME_LENGTH }
|
||||
|
||||
export { type InstanceGroupDefinition, type InstanceGroupMembershipUpdate }
|
||||
|
||||
export interface InstanceGroupWithMembership {
|
||||
key: string
|
||||
name: string
|
||||
instances: string[]
|
||||
}
|
||||
|
||||
export function useInstanceGroups(instances: Ref<{ id: string; groups: string[] }[]>) {
|
||||
const libraryGroups = ref<InstanceGroupDefinition[]>([])
|
||||
const libraryGroupsLoaded = ref(false)
|
||||
|
||||
async function fetchGroups() {
|
||||
libraryGroups.value = await list_groups()
|
||||
libraryGroupsLoaded.value = true
|
||||
}
|
||||
|
||||
fetchGroups()
|
||||
|
||||
instance_groups_listener(() => {
|
||||
fetchGroups()
|
||||
})
|
||||
|
||||
const orderedLibraryGroupIds = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
libraryGroups,
|
||||
(groups) => {
|
||||
orderedLibraryGroupIds.value = groups.map((g) => g.id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const instanceGroups = computed(() => {
|
||||
if (!libraryGroupsLoaded.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const instanceGroupMap = new Map<string, string[]>()
|
||||
|
||||
for (const instance of instances.value) {
|
||||
const groups = instance.groups ?? []
|
||||
for (const groupId of groups) {
|
||||
if (!instanceGroupMap.has(groupId)) {
|
||||
instanceGroupMap.set(groupId, [])
|
||||
}
|
||||
instanceGroupMap.get(groupId)!.push(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
const allGroupIds = new Set<string>()
|
||||
const groupMap = new Map<string, string[]>()
|
||||
|
||||
for (const groupId of orderedLibraryGroupIds.value) {
|
||||
allGroupIds.add(groupId)
|
||||
groupMap.set(groupId, instanceGroupMap.get(groupId) ?? [])
|
||||
}
|
||||
|
||||
const ungroupedIds: string[] = []
|
||||
for (const instance of instances.value) {
|
||||
const instanceGroups = instance.groups ?? []
|
||||
const isInAnyGroup = instanceGroups.some((g) => allGroupIds.has(g))
|
||||
if (!isInAnyGroup) {
|
||||
ungroupedIds.push(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (ungroupedIds.length > 0) {
|
||||
groupMap.set(UNGROUPED_GROUP_ID, ungroupedIds)
|
||||
}
|
||||
|
||||
return [...groupMap.entries()].map(([key, instances]) => ({
|
||||
key,
|
||||
name:
|
||||
key === UNGROUPED_GROUP_ID
|
||||
? 'Ungrouped'
|
||||
: (libraryGroups.value.find((g) => g.id === key)?.name ?? key),
|
||||
instances,
|
||||
}))
|
||||
})
|
||||
|
||||
const creatingGroup = ref(false)
|
||||
const reorderingGroups = ref(false)
|
||||
|
||||
async function createNewGroup(name: string): Promise<string | null> {
|
||||
creatingGroup.value = true
|
||||
try {
|
||||
const group = await create_group(name)
|
||||
libraryGroups.value = [...libraryGroups.value, group]
|
||||
orderedLibraryGroupIds.value = [...orderedLibraryGroupIds.value, group.id]
|
||||
return group.id
|
||||
} finally {
|
||||
creatingGroup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renameGroupById(id: string, newName: string): Promise<boolean> {
|
||||
if (id === FAVORITES_GROUP_ID) return false
|
||||
const result = await rename_group(id, newName)
|
||||
const idx = libraryGroups.value.findIndex((g) => g.id === id)
|
||||
if (idx !== -1) {
|
||||
libraryGroups.value = [
|
||||
...libraryGroups.value.slice(0, idx),
|
||||
result,
|
||||
...libraryGroups.value.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function deleteGroupById(id: string): Promise<boolean> {
|
||||
if (id === FAVORITES_GROUP_ID) return false
|
||||
await delete_group(id)
|
||||
libraryGroups.value = libraryGroups.value.filter((g) => g.id !== id)
|
||||
orderedLibraryGroupIds.value = orderedLibraryGroupIds.value.filter((gid) => gid !== id)
|
||||
return true
|
||||
}
|
||||
|
||||
async function reorderGroups(groupIds: string[]): Promise<boolean> {
|
||||
reorderingGroups.value = true
|
||||
try {
|
||||
await set_group_order(groupIds)
|
||||
// Favorites is rendered separately from draggable sections. Keep it in
|
||||
// the local order after a drag even though the backend accepts only the
|
||||
// reorderable group ids; otherwise it appears again as an ordinary
|
||||
// section until the next full group refresh.
|
||||
orderedLibraryGroupIds.value = [
|
||||
FAVORITES_GROUP_ID,
|
||||
...groupIds.filter((id) => id !== FAVORITES_GROUP_ID),
|
||||
]
|
||||
return true
|
||||
} finally {
|
||||
reorderingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setMemberships(updates: InstanceGroupMembershipUpdate[]): Promise<boolean> {
|
||||
await set_group_memberships(updates)
|
||||
return true
|
||||
}
|
||||
|
||||
function groupNameById(id: string): string | undefined {
|
||||
if (id === UNGROUPED_GROUP_ID) return 'Ungrouped'
|
||||
return libraryGroups.value.find((g) => g.id === id)?.name
|
||||
}
|
||||
|
||||
return {
|
||||
libraryGroups,
|
||||
libraryGroupsLoaded,
|
||||
orderedLibraryGroupIds,
|
||||
instanceGroups,
|
||||
creatingGroup,
|
||||
reorderingGroups,
|
||||
createGroup: createNewGroup,
|
||||
renameGroupById,
|
||||
deleteGroupById,
|
||||
reorderGroups,
|
||||
setMemberships,
|
||||
groupNameById,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
type KeyBinding,
|
||||
bindingMatchesKeyboardEvent,
|
||||
bindingMatchesMouseEvent,
|
||||
bindingMatchesWheelEvent,
|
||||
useModalStack,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import {
|
||||
SCROLL_ACTIONS,
|
||||
SHORTCUT_ACTIONS,
|
||||
type ShortcutAction,
|
||||
} from '@/helpers/shortcut-actions'
|
||||
import { resolveAllBindings } from '@/helpers/shortcut-bindings'
|
||||
import { getNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
|
||||
import { getQuickScrollEnabled } from '@/helpers/scroll-top-state'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
/** Editing surfaces keep these keys for themselves. */
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
)
|
||||
}
|
||||
|
||||
function scrollsAtOwnLevel(element: Element) {
|
||||
const { overflowY } = getComputedStyle(element)
|
||||
return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight
|
||||
}
|
||||
|
||||
/** A container that scrolls the page itself rather than a widget on it. */
|
||||
function isPageScroller(element: Element) {
|
||||
return scrollsAtOwnLevel(element) && element.clientHeight >= window.innerHeight / 2
|
||||
}
|
||||
|
||||
/** Nearest scrolling ancestor, whatever its size. */
|
||||
function nearestScrollContainer(target: EventTarget | null) {
|
||||
let element = target instanceof Element ? target : null
|
||||
while (element) {
|
||||
if (scrollsAtOwnLevel(element)) return element
|
||||
element = element.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's own scroller. Most pages scroll inside `.app-viewport`, but some
|
||||
* screens (settings, consoles, studios) turn that element's scrolling off and
|
||||
* host a full-height container inside it, so fall back to whatever fills the
|
||||
* middle of the viewport.
|
||||
*/
|
||||
function resolvePageScroller() {
|
||||
const viewport = document.querySelector<HTMLElement>('.app-viewport')
|
||||
if (!viewport) return null
|
||||
if (isPageScroller(viewport)) return viewport
|
||||
|
||||
const bounds = viewport.getBoundingClientRect()
|
||||
let element: Element | null = document.elementFromPoint(
|
||||
bounds.left + bounds.width / 2,
|
||||
bounds.top + bounds.height / 2,
|
||||
)
|
||||
while (element) {
|
||||
if (isPageScroller(element)) return element
|
||||
element = element.parentElement
|
||||
}
|
||||
|
||||
return viewport
|
||||
}
|
||||
|
||||
export function setupKeyboardShortcuts() {
|
||||
const themeStore = useTheming()
|
||||
const { hasModal } = useModalStack()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
themeStore.quickScrollEnabled = getQuickScrollEnabled()
|
||||
for (const shortcut of SHORTCUT_ACTIONS) {
|
||||
if (shortcut.group === 'nav') themeStore[shortcut.enabledField] = getNavShortcutEnabled(shortcut.id)
|
||||
}
|
||||
themeStore.shortcutBindings = resolveAllBindings()
|
||||
|
||||
/** The combination an action answers to now: recorded if there is one, default otherwise. */
|
||||
function bindingFor(action: ShortcutAction): KeyBinding {
|
||||
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
|
||||
}
|
||||
|
||||
/** Whether an action is switched on and reachable in the current state. */
|
||||
function shortcutIsAvailable(action: ShortcutAction): boolean {
|
||||
if (!themeStore[action.enabledField]) return false
|
||||
return !action.unavailable?.({
|
||||
worldsTabEnabled: themeStore.featureFlags.worlds_tab,
|
||||
offline: offline.value,
|
||||
})
|
||||
}
|
||||
|
||||
function findShortcut<E extends Event>(
|
||||
event: E,
|
||||
matches: (binding: KeyBinding, event: E) => boolean,
|
||||
{ includeDisabled = false }: { includeDisabled?: boolean } = {},
|
||||
): ShortcutAction | null {
|
||||
if (hasModal.value) return null
|
||||
if (isEditableTarget(event.target)) return null
|
||||
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
if (!includeDisabled && !shortcutIsAvailable(action)) continue
|
||||
if (matches(bindingFor(action), event)) return action
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Does what an action is for. Scrolling actions move the page's own scroller. */
|
||||
function runShortcut(action: ShortcutAction, event: Event) {
|
||||
if (action.target) {
|
||||
router.push(action.target(route))
|
||||
return
|
||||
}
|
||||
|
||||
const focusedContainer = nearestScrollContainer(event.target)
|
||||
if (focusedContainer && !isPageScroller(focusedContainer)) return
|
||||
|
||||
const scroller = resolvePageScroller()
|
||||
if (!scroller) return
|
||||
|
||||
action.applyScroll?.(scroller)
|
||||
}
|
||||
|
||||
function handleScrollShortcutKey(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
|
||||
const match = findShortcut(event, bindingMatchesKeyboardEvent, { includeDisabled: true })
|
||||
if (match?.group !== 'scroll') return
|
||||
|
||||
const focusedContainer = nearestScrollContainer(event.target)
|
||||
if (focusedContainer && !isPageScroller(focusedContainer)) return
|
||||
|
||||
const scroller = resolvePageScroller()
|
||||
if (!scroller) return
|
||||
|
||||
event.preventDefault()
|
||||
if (!shortcutIsAvailable(match)) return
|
||||
|
||||
match.applyScroll?.(scroller)
|
||||
}
|
||||
|
||||
function handleNavShortcutKey(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
|
||||
const match = findShortcut(event, bindingMatchesKeyboardEvent)
|
||||
if (match?.group !== 'nav') return
|
||||
|
||||
event.preventDefault()
|
||||
runShortcut(match, event)
|
||||
}
|
||||
|
||||
const hasPointerBindings = computed(() =>
|
||||
SHORTCUT_ACTIONS.some((action) => bindingFor(action).device === 'mouse'),
|
||||
)
|
||||
|
||||
function handleShortcutMouse(event: MouseEvent) {
|
||||
if (!hasPointerBindings.value) return
|
||||
|
||||
const match = findShortcut(event, bindingMatchesMouseEvent)
|
||||
if (!match) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
runShortcut(match, event)
|
||||
}
|
||||
|
||||
function handleShortcutWheel(event: WheelEvent) {
|
||||
if (!hasPointerBindings.value) return
|
||||
|
||||
const match = findShortcut(event, bindingMatchesWheelEvent)
|
||||
if (!match) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
runShortcut(match, event)
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
const key = event.key.toLowerCase()
|
||||
const isFindShortcut = key === 'f' && (event.ctrlKey || event.metaKey)
|
||||
const isBlockedDevtoolsShortcut = event.key === 'F12' && !themeStore.devMode
|
||||
|
||||
if (isFindShortcut || isBlockedDevtoolsShortcut) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
handleScrollShortcutKey(event)
|
||||
handleNavShortcutKey(event)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeydown, true)
|
||||
window.addEventListener('mousedown', handleShortcutMouse, true)
|
||||
// Not passive: a bound wheel direction has to keep the page from scrolling.
|
||||
window.addEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleGlobalKeydown, true)
|
||||
window.removeEventListener('mousedown', handleShortcutMouse, true)
|
||||
window.removeEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
|
||||
})
|
||||
|
||||
return { bindingFor, shortcutIsAvailable, SCROLL_ACTIONS }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
interface InstanceProperties {
|
||||
loader: string
|
||||
game_version: string
|
||||
}
|
||||
|
||||
interface ProjectProperties extends InstanceProperties {
|
||||
id: string
|
||||
project_type: string
|
||||
}
|
||||
|
||||
type AnalyticsEventMap = {
|
||||
Launched: { version: string; dev: boolean; onboarded: boolean }
|
||||
PageView: { path: string; fromPath: string; failed: unknown }
|
||||
InstanceCreate: { source: string }
|
||||
InstanceCreateStart: { source: string }
|
||||
InstanceStart: InstanceProperties & { source: string }
|
||||
InstanceStop: Partial<InstanceProperties> & { source?: string }
|
||||
InstanceDuplicate: InstanceProperties
|
||||
InstanceRepair: InstanceProperties
|
||||
InstanceSetIcon: Record<string, never>
|
||||
InstanceRemoveIcon: Record<string, never>
|
||||
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
|
||||
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
|
||||
InstanceProjectDisable: InstanceProperties & {
|
||||
id: string
|
||||
name: string
|
||||
project_type: string
|
||||
disabled: boolean
|
||||
}
|
||||
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
|
||||
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
|
||||
ProjectInstallStart: { source: string }
|
||||
PackInstall: { id: string; version_id: string; title: string; source: string }
|
||||
PackInstallStart: Record<string, never>
|
||||
AccountLogIn: { source?: string }
|
||||
AccountLogOut: Record<string, never>
|
||||
JavaTest: { path: string; success: boolean }
|
||||
JavaManualSelect: { version: string }
|
||||
JavaAutoDetect: { path: string; version: string }
|
||||
GalleryImageNext: { project_id: string; url: string }
|
||||
GalleryImagePrevious: { project_id: string; url: unknown }
|
||||
GalleryImageExpand: { project_id: string; url: string }
|
||||
}
|
||||
|
||||
export type AnalyticsEvent = keyof AnalyticsEventMap
|
||||
|
||||
let optedIn = false
|
||||
let debugEnabled = false
|
||||
|
||||
export const initAnalytics = () => {
|
||||
optedIn = true
|
||||
}
|
||||
|
||||
export const debugAnalytics = () => {
|
||||
debugEnabled = true
|
||||
}
|
||||
|
||||
export const optOutAnalytics = () => {
|
||||
optedIn = false
|
||||
}
|
||||
|
||||
export const optInAnalytics = () => {
|
||||
optedIn = true
|
||||
}
|
||||
|
||||
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
|
||||
|
||||
export const trackEvent = <E extends AnalyticsEvent>(
|
||||
eventName: E,
|
||||
...args: OptionalArgs<AnalyticsEventMap[E]>
|
||||
) => {
|
||||
if (optedIn && debugEnabled) {
|
||||
console.debug('[Starlight telemetry disabled]', eventName, args[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Starlight anniversary helpers.
|
||||
*
|
||||
* Starlight's first release was on 2014-10-04, so every October 4th the
|
||||
* launcher shows a small celebration. The anniversary number is simply
|
||||
* `currentYear - 2014`.
|
||||
*/
|
||||
|
||||
/** Year of Starlight's first release. */
|
||||
export const STARLIGHT_FOUNDING_YEAR = 2014
|
||||
|
||||
/** Month (1-12) of Starlight's anniversary. */
|
||||
export const STARLIGHT_ANNIVERSARY_MONTH = 10
|
||||
|
||||
/** Day of month of Starlight's anniversary. */
|
||||
export const STARLIGHT_ANNIVERSARY_DAY = 4
|
||||
|
||||
/**
|
||||
* Whether the given date falls on Starlight's anniversary (October 4th).
|
||||
* Uses the local timezone, which is what the launcher user experiences.
|
||||
*/
|
||||
export function isAnniversary(date: Date = new Date()): boolean {
|
||||
return (
|
||||
date.getMonth() + 1 === STARLIGHT_ANNIVERSARY_MONTH &&
|
||||
date.getDate() === STARLIGHT_ANNIVERSARY_DAY
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The anniversary number for the given date (e.g. 12 for 2026).
|
||||
* Not clamped: callers decide whether to guard against pre-2014 dates.
|
||||
*/
|
||||
export function anniversaryYear(date: Date = new Date()): number {
|
||||
return date.getFullYear() - STARLIGHT_FOUNDING_YEAR
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the celebration should currently be shown.
|
||||
*
|
||||
* Normally this is only true on October 4th, but the developer-page
|
||||
* `force_anniversary_celebration` feature flag forces it on for previewing.
|
||||
*/
|
||||
export function shouldCelebrate(force = false, date: Date = new Date()): boolean {
|
||||
return force || isAnniversary(date)
|
||||
}
|
||||
|
||||
/** English ordinal suffix for a number (1 -> "1st", 12 -> "12th"). */
|
||||
export function ordinal(n: number): string {
|
||||
const mod100 = n % 100
|
||||
if (mod100 >= 11 && mod100 <= 13) return `${n}th`
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return `${n}st`
|
||||
case 2:
|
||||
return `${n}nd`
|
||||
case 3:
|
||||
return `${n}rd`
|
||||
default:
|
||||
return `${n}th`
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,16 @@ export async function instance_bulk_update_progress_listener(callback) {
|
||||
return await listen('instance_bulk_update_progress', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'instance_groups_changed' event
|
||||
/*
|
||||
InstanceGroupsChangedPayload {
|
||||
instance_ids: string[]
|
||||
}
|
||||
*/
|
||||
export async function instance_groups_listener(callback) {
|
||||
return await listen('instance_groups_changed', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function install_job_listener(callback) {
|
||||
return await listen('install_job', (event) => callback(event.payload))
|
||||
}
|
||||
@@ -150,6 +160,10 @@ export async function notification_listener(callback) {
|
||||
return await listen('notification', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function logshare_ai_listener(callback) {
|
||||
return await listen('logshare_ai', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'log' event
|
||||
/*
|
||||
LogPayload {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Font family plumbing shared by the appearance settings pickers and the theme
|
||||
* store. Kept free of imports so `node --test` can load it directly.
|
||||
*/
|
||||
|
||||
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
|
||||
export const DEFAULT_UI_FONT_STACK =
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
|
||||
|
||||
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
|
||||
export const DEFAULT_MONO_FONT_STACK =
|
||||
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
|
||||
/** Picker value for "follow the launcher default"; persisted as `null`. */
|
||||
export const FOLLOW_DEFAULT_FONT = ''
|
||||
|
||||
export interface SystemFontFamily {
|
||||
family: string
|
||||
monospaced: boolean
|
||||
}
|
||||
|
||||
export interface FontFamilyOption {
|
||||
value: string
|
||||
label: string
|
||||
subLabel?: string
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
|
||||
|
||||
export interface FontFamilyOptionsConfig {
|
||||
defaultLabel: string
|
||||
selected?: string | null
|
||||
missingLabel?: string
|
||||
monospaceLabel?: string
|
||||
groupMonospaced?: boolean
|
||||
}
|
||||
|
||||
const MAX_FONT_FAMILY_LENGTH = 128
|
||||
|
||||
/**
|
||||
* Quotes and escapes an installed family name so it cannot break out of the
|
||||
* font-family declaration it is spliced into. Returns null when the name has
|
||||
* nothing usable left.
|
||||
*/
|
||||
export function cssFontFamilyName(name: string): string | null {
|
||||
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
|
||||
|
||||
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* The value written into `--font-standard` / `--mono-font`. The launcher stack
|
||||
* always trails the chosen family so an unresolvable name degrades to the
|
||||
* previous default instead of the user agent's own font.
|
||||
*/
|
||||
export function resolveFontFamily(
|
||||
setting: string | null | undefined,
|
||||
fallbackStack: string,
|
||||
): string {
|
||||
if (!setting) {
|
||||
return fallbackStack
|
||||
}
|
||||
|
||||
const family = cssFontFamilyName(setting)
|
||||
|
||||
return family ? `${family}, ${fallbackStack}` : fallbackStack
|
||||
}
|
||||
|
||||
export function fontSettingToOptionValue(setting: string | null | undefined): string {
|
||||
return setting ?? FOLLOW_DEFAULT_FONT
|
||||
}
|
||||
|
||||
export function optionValueToFontSetting(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
|
||||
}
|
||||
|
||||
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
|
||||
const left = first.family.toLowerCase()
|
||||
const right = second.family.toLowerCase()
|
||||
|
||||
if (left === right) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
|
||||
return {
|
||||
value: family.family,
|
||||
label: family.family,
|
||||
...(family.monospaced && monospaceLabel
|
||||
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The spelling the font list uses for a stored family. Font names are matched
|
||||
* case-insensitively so a stored value that only differs in case still selects
|
||||
* its option instead of leaving the picker blank.
|
||||
*/
|
||||
export function canonicalFontFamily(
|
||||
fonts: SystemFontFamily[],
|
||||
setting: string | null | undefined,
|
||||
): string {
|
||||
const selected = optionValueToFontSetting(setting ?? '')
|
||||
if (!selected) {
|
||||
return FOLLOW_DEFAULT_FONT
|
||||
}
|
||||
|
||||
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
|
||||
|
||||
return listed ? listed.family.trim() : selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the picker options for the installed font collection: the default
|
||||
* entry first, the current selection re-added when it is no longer installed,
|
||||
* and — for the monospace picker — monospaced families ahead of the rest.
|
||||
*/
|
||||
export function buildFontFamilyOptions(
|
||||
fonts: SystemFontFamily[],
|
||||
config: FontFamilyOptionsConfig,
|
||||
): FontFamilyOptionOrDivider[] {
|
||||
const families = new Map<string, SystemFontFamily>()
|
||||
for (const font of fonts) {
|
||||
const family = font.family.trim()
|
||||
|
||||
if (!family) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = family.toLowerCase()
|
||||
const known = families.get(key)
|
||||
|
||||
if (known) {
|
||||
known.monospaced ||= font.monospaced
|
||||
} else {
|
||||
families.set(key, { family, monospaced: font.monospaced })
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...families.values()].sort(compareFamilies)
|
||||
const selected = optionValueToFontSetting(config.selected ?? '')
|
||||
const options: FontFamilyOptionOrDivider[] = [
|
||||
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
|
||||
]
|
||||
|
||||
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
|
||||
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
|
||||
}
|
||||
|
||||
if (config.groupMonospaced) {
|
||||
const monospaced = sorted.filter((font) => font.monospaced)
|
||||
const proportional = sorted.filter((font) => !font.monospaced)
|
||||
|
||||
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
|
||||
|
||||
if (monospaced.length > 0 && proportional.length > 0) {
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
options.push(...sorted.map((font) => toOption(font)))
|
||||
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type GameOptionCanonicalValue =
|
||||
| { type: 'bool'; value: boolean }
|
||||
| { type: 'integer'; value: number }
|
||||
| { type: 'decimal'; value: string }
|
||||
| { type: 'enum'; value: string }
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'string_list'; value: string[] }
|
||||
| { type: 'key_binding'; value: string }
|
||||
| { type: 'external_raw'; value: string }
|
||||
|
||||
export type GameOptionValueState = 'canonical' | 'uniform_local' | 'mixed' | 'unset' | 'invalid'
|
||||
|
||||
export type GameOptionCompatibilityStatus =
|
||||
| 'ready'
|
||||
| 'deferred'
|
||||
| 'waiting_for_file'
|
||||
| 'waiting_for_base'
|
||||
| 'not_available'
|
||||
| 'unsupported_value'
|
||||
| 'unmappable'
|
||||
| 'catalog_uncovered'
|
||||
| 'controlled'
|
||||
| 'degraded'
|
||||
|
||||
export type GameOptionMappingKind = 'direct' | 'legacy' | 'migrated' | 'lossless' | 'lossy'
|
||||
|
||||
export type GameOptionCompatibilityReason =
|
||||
| 'launcher_controlled'
|
||||
| 'catalog_uncovered'
|
||||
| 'inspection_failed'
|
||||
| 'waiting_for_options_file'
|
||||
| 'unsupported_value'
|
||||
| 'migrates_on_write'
|
||||
| 'waiting_for_compatible_base'
|
||||
| 'missing_setting'
|
||||
|
||||
export type GameOptionCompatibilityBucket = {
|
||||
instance_count: number
|
||||
write_keys: string[]
|
||||
eventual_keys: string[]
|
||||
game_versions: string[]
|
||||
status: GameOptionCompatibilityStatus
|
||||
mapping?: GameOptionMappingKind | null
|
||||
reason?: GameOptionCompatibilityReason | null
|
||||
}
|
||||
|
||||
export type GameOptionCompatibilitySummary = {
|
||||
total_participating: number
|
||||
will_receive: number
|
||||
write_now: number
|
||||
left_local: number
|
||||
buckets: GameOptionCompatibilityBucket[]
|
||||
}
|
||||
|
||||
export type GameOptionEditorChoice = {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type GameOptionEditor = {
|
||||
type:
|
||||
| 'bool'
|
||||
| 'integer'
|
||||
| 'decimal'
|
||||
| 'enum'
|
||||
| 'text'
|
||||
| 'string_list'
|
||||
| 'key_binding'
|
||||
| 'external_raw'
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
step?: number | null
|
||||
unit?: string | null
|
||||
choices?: GameOptionEditorChoice[]
|
||||
}
|
||||
|
||||
export type GameSettingCategory = {
|
||||
id: string
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export type GameOptionValidationError =
|
||||
'missing_value' | 'no_compatible_instances' | 'invalid_value' | 'changed_since_opened'
|
||||
|
||||
export type EditableGameSetting = {
|
||||
option_id: string
|
||||
category_id: string
|
||||
kind: 'vanilla' | 'external'
|
||||
raw_key?: string | null
|
||||
sync_enabled: boolean
|
||||
canonical_value?: GameOptionCanonicalValue | null
|
||||
value_state: GameOptionValueState
|
||||
option_revision: number
|
||||
editor: GameOptionEditor
|
||||
compatibility: GameOptionCompatibilitySummary
|
||||
validation_error?: GameOptionValidationError | null
|
||||
controlled?: boolean
|
||||
}
|
||||
|
||||
export type GameSettingsEditorState = {
|
||||
summary_revision: string
|
||||
canonical_revision: number
|
||||
catalog_revision: number
|
||||
total_participating: number
|
||||
categories: GameSettingCategory[]
|
||||
settings: EditableGameSetting[]
|
||||
}
|
||||
|
||||
export type GameSettingChange = {
|
||||
option_id: string
|
||||
base_option_revision: number
|
||||
sync_enabled?: boolean
|
||||
canonical_value?: GameOptionCanonicalValue | null
|
||||
}
|
||||
|
||||
export type UpdateGameSettingsRequest = {
|
||||
expected_summary_revision: string
|
||||
expected_canonical_revision: number
|
||||
expected_catalog_revision: number
|
||||
changes: GameSettingChange[]
|
||||
}
|
||||
|
||||
export type GameOptionsSourceCandidate = {
|
||||
source_id: string
|
||||
instance_id: string
|
||||
name: string
|
||||
icon_path?: string | null
|
||||
game_version?: string | null
|
||||
eligible: boolean
|
||||
disabled_reason?: GameOptionsSourceDisabledReason | null
|
||||
recognized_setting_count: number
|
||||
custom_setting_count: number
|
||||
}
|
||||
|
||||
export type GameOptionsSourceDisabledReason =
|
||||
| 'installing_or_updating'
|
||||
| 'unsupported_version'
|
||||
| 'missing_options_file'
|
||||
| 'no_syncable_settings'
|
||||
| 'unreadable_options_file'
|
||||
|
||||
export type SaveGameSettingsResult = {
|
||||
state?: GameSettingsEditorState
|
||||
applied: number
|
||||
migrated: number
|
||||
deferred: number
|
||||
unsupported: number
|
||||
failed: number
|
||||
conflicts?: string[]
|
||||
}
|
||||
|
||||
export type GameSettingLocaleLabel = {
|
||||
label: string
|
||||
choices: Record<string, string>
|
||||
source?: GameSettingSource | null
|
||||
}
|
||||
|
||||
export type GameSettingSource = {
|
||||
instance_id: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
icon_url: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export async function get_game_setting_locale_labels(
|
||||
instanceId: string | undefined,
|
||||
locale: string,
|
||||
optionIds: string[],
|
||||
refreshSources = false,
|
||||
): Promise<{ settings: Record<string, GameSettingLocaleLabel> }> {
|
||||
return await invoke('plugin:instance|instance_get_game_setting_locale_labels', {
|
||||
instanceId,
|
||||
locale,
|
||||
optionIds,
|
||||
refreshSources,
|
||||
})
|
||||
}
|
||||
|
||||
export async function list_game_options_sync_sources(): Promise<GameOptionsSourceCandidate[]> {
|
||||
return await invoke('plugin:instance|instance_list_game_options_sync_sources')
|
||||
}
|
||||
|
||||
export async function get_synced_game_options_config(): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_get_synced_game_options_config')
|
||||
}
|
||||
|
||||
export const gameSettingsKeys = {
|
||||
synced: ['game-settings', 'synced'] as const,
|
||||
local: (instanceId: string) => ['game-settings', 'local', instanceId] as const,
|
||||
}
|
||||
|
||||
export function gameSettingsQueryOptions(instanceId?: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceId ? gameSettingsKeys.local(instanceId) : gameSettingsKeys.synced,
|
||||
queryFn: () =>
|
||||
instanceId ? get_local_game_options_config(instanceId) : get_synced_game_options_config(),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function preview_synced_game_option_changes(
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_preview_synced_game_option_changes', { request })
|
||||
}
|
||||
|
||||
export async function save_synced_game_option_changes(
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<SaveGameSettingsResult> {
|
||||
return await invoke('plugin:instance|instance_save_synced_game_option_changes', { request })
|
||||
}
|
||||
|
||||
export async function get_local_game_options_config(
|
||||
instanceId: string,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_get_local_game_options_config', { instanceId })
|
||||
}
|
||||
|
||||
export async function preview_local_game_option_changes(
|
||||
instanceId: string,
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_preview_local_game_option_changes', {
|
||||
instanceId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_local_game_option_changes(
|
||||
instanceId: string,
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<SaveGameSettingsResult> {
|
||||
return await invoke('plugin:instance|instance_save_local_game_option_changes', {
|
||||
instanceId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import {
|
||||
requestSkinSiteDownloadToken,
|
||||
requestSkinSitePlayers,
|
||||
skinSitePlayers,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
waitForSkinSiteSession,
|
||||
} from '../composables/skin-site-session.ts'
|
||||
import { authenticateInstancePlayer } from './instance-player.ts'
|
||||
|
||||
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
|
||||
|
||||
@@ -24,10 +27,36 @@ function startHostedPackAttempt(instanceId: string) {
|
||||
}
|
||||
|
||||
export async function requireHostedGameAccount(): Promise<void> {
|
||||
const available = await invokeHosted<boolean>('plugin:install|hosted_has_game_account')
|
||||
if (!available) {
|
||||
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
|
||||
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) return
|
||||
|
||||
// No game credential yet, but a signed-in skin site already lists the
|
||||
// user's players. Authenticate the first one automatically so the user
|
||||
// does not have to pick a player manually before creating an instance.
|
||||
await waitForSkinSiteSession()
|
||||
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
|
||||
if (skinSitePlayers.value.length === 0) {
|
||||
await requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
const player =
|
||||
skinSitePlayers.value.find((candidate) => !candidate.isMojang) ?? skinSitePlayers.value[0]
|
||||
if (player) {
|
||||
try {
|
||||
await authenticateInstancePlayer({
|
||||
id: player.uuid,
|
||||
name: player.name,
|
||||
account_type: 'yggdrasil',
|
||||
skin_site_user: skinSiteUser.value.uuid,
|
||||
})
|
||||
} catch {
|
||||
// Fall through to the login-required error below.
|
||||
}
|
||||
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
|
||||
}
|
||||
|
||||
export function clearHostedSession(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts'
|
||||
|
||||
test('directory exclusions display with a trailing slash', () => {
|
||||
assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/')
|
||||
assert.equal(
|
||||
formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }),
|
||||
'config/somemod/bigdb.sqlite',
|
||||
)
|
||||
})
|
||||
|
||||
test('directory exclusions cover descendants while file exclusions stay exact', () => {
|
||||
assert.deepEqual(
|
||||
canonicalizeBackupExclusions([
|
||||
{ path: 'saves/world', kind: 'directory' },
|
||||
{ path: 'config\\bigdb.sqlite', kind: 'file' },
|
||||
{ path: 'saves', kind: 'directory' },
|
||||
{ path: 'config', kind: 'file' },
|
||||
]),
|
||||
[
|
||||
{ path: 'config', kind: 'file' },
|
||||
{ path: 'saves', kind: 'directory' },
|
||||
{ path: 'config/bigdb.sqlite', kind: 'file' },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('directory wins when the same exclusion is selected as both kinds', () => {
|
||||
assert.deepEqual(
|
||||
canonicalizeBackupExclusions([
|
||||
{ path: 'cache', kind: 'file' },
|
||||
{ path: 'cache', kind: 'directory' },
|
||||
]),
|
||||
[{ path: 'cache', kind: 'directory' }],
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
|
||||
export type InstanceBackupEligibility =
|
||||
| 'eligible'
|
||||
| 'not_installed'
|
||||
| 'symlink_instance'
|
||||
| 'direct_linked_instance'
|
||||
| 'external_game_directory'
|
||||
| 'root_missing'
|
||||
| 'root_is_link'
|
||||
|
||||
export interface BackupRepositoryStatus {
|
||||
path: string
|
||||
initialized: boolean
|
||||
available: boolean
|
||||
stored_size: number
|
||||
logical_size: number
|
||||
snapshot_count: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type BackupExclusionKind = 'file' | 'directory'
|
||||
|
||||
export interface BackupExclusion {
|
||||
path: string
|
||||
kind: BackupExclusionKind
|
||||
}
|
||||
|
||||
export function formatBackupExclusionPath(exclusion: BackupExclusion): string {
|
||||
return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path
|
||||
}
|
||||
|
||||
export interface InstanceBackupConfig {
|
||||
instance_id: string
|
||||
enabled: boolean
|
||||
eligibility: InstanceBackupEligibility
|
||||
excluded_paths: BackupExclusion[]
|
||||
snapshot_count: number
|
||||
}
|
||||
|
||||
export interface BackupSnapshot {
|
||||
id: string
|
||||
instance_id: string
|
||||
instance_name: string
|
||||
created_at: number
|
||||
file_count: number
|
||||
symlink_count: number
|
||||
logical_size: number
|
||||
added_size: number
|
||||
}
|
||||
|
||||
export interface BackupDeleteSummary {
|
||||
snapshot_count: number
|
||||
logical_size: number
|
||||
}
|
||||
|
||||
export interface BackupRestorePreview {
|
||||
added_files: number
|
||||
modified_files: number
|
||||
deleted_files: number
|
||||
plan_token: string
|
||||
}
|
||||
|
||||
export type BackupOperationType =
|
||||
'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move'
|
||||
export type BackupOperationState =
|
||||
| 'queued'
|
||||
| 'scanning'
|
||||
| 'hashing'
|
||||
| 'saving'
|
||||
| 'validating'
|
||||
| 'materializing'
|
||||
| 'applying'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
|
||||
export interface BackupOperation {
|
||||
id: string
|
||||
operation_type: BackupOperationType
|
||||
instance_id: string
|
||||
snapshot_id?: string
|
||||
state: BackupOperationState
|
||||
processed_bytes: number
|
||||
total_bytes: number
|
||||
cancellable: boolean
|
||||
cancel_requested: boolean
|
||||
error?: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
finished_at?: number
|
||||
restore_preview?: BackupRestorePreview
|
||||
}
|
||||
|
||||
export type BackupProgressStage =
|
||||
| 'scanning'
|
||||
| 'hashing'
|
||||
| 'saving'
|
||||
| 'validating'
|
||||
| 'copying'
|
||||
| 'restoring'
|
||||
| 'deleting'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed'
|
||||
|
||||
export interface BackupProgressEvent {
|
||||
operationId: string
|
||||
instanceId?: string
|
||||
operationType: BackupOperationType
|
||||
stage: BackupProgressStage
|
||||
processedBytes: number
|
||||
totalBytes: number
|
||||
finalState?: BackupOperationFinalState
|
||||
snapshotId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] {
|
||||
const normalized = values
|
||||
.map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.path.split('/').length - right.path.split('/').length ||
|
||||
left.path.localeCompare(right.path),
|
||||
)
|
||||
const result: BackupExclusion[] = []
|
||||
for (const value of normalized) {
|
||||
const duplicate = result.find((existing) => existing.path === value.path)
|
||||
if (duplicate) {
|
||||
if (value.kind === 'directory') duplicate.kind = 'directory'
|
||||
continue
|
||||
}
|
||||
if (
|
||||
result.some(
|
||||
(parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getBackupRepositoryStatus(): Promise<BackupRepositoryStatus> {
|
||||
return invoke('plugin:instance|instance_get_backup_repository_status')
|
||||
}
|
||||
|
||||
export function moveBackupRepository(destination: string): Promise<string> {
|
||||
return invoke('plugin:instance|instance_move_backup_repository', { destination })
|
||||
}
|
||||
|
||||
export function getBackupConfig(instanceId: string): Promise<InstanceBackupConfig> {
|
||||
return invoke('plugin:instance|instance_get_backup_config', { instanceId })
|
||||
}
|
||||
|
||||
export function normalizeBackupExclusion(
|
||||
instanceId: string,
|
||||
selectedPath: string,
|
||||
kind: BackupExclusionKind,
|
||||
): Promise<BackupExclusion> {
|
||||
return invoke('plugin:instance|instance_normalize_backup_exclusion', {
|
||||
instanceId,
|
||||
selectedPath,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
export function enableBackups(
|
||||
instanceId: string,
|
||||
excludedPaths: BackupExclusion[],
|
||||
): Promise<InstanceBackupConfig> {
|
||||
return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths })
|
||||
}
|
||||
|
||||
export function updateBackupExclusions(
|
||||
instanceId: string,
|
||||
excludedPaths: BackupExclusion[],
|
||||
): Promise<InstanceBackupConfig> {
|
||||
return invoke('plugin:instance|instance_update_backup_exclusions', {
|
||||
instanceId,
|
||||
excludedPaths,
|
||||
})
|
||||
}
|
||||
|
||||
export function disableBackups(instanceId: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_disable_backups', { instanceId })
|
||||
}
|
||||
|
||||
export function startBackup(instanceId: string): Promise<string> {
|
||||
return invoke('plugin:instance|instance_start_backup', { instanceId })
|
||||
}
|
||||
|
||||
export function cancelBackup(operationId: string): Promise<boolean> {
|
||||
return invoke('plugin:instance|instance_cancel_backup', { operationId })
|
||||
}
|
||||
|
||||
export function listBackupOperations(
|
||||
instanceId?: string,
|
||||
activeOnly = false,
|
||||
): Promise<BackupOperation[]> {
|
||||
return invoke('plugin:instance|instance_list_backup_operations', {
|
||||
instanceId,
|
||||
activeOnly,
|
||||
})
|
||||
}
|
||||
|
||||
export function listBackups(instanceId: string): Promise<BackupSnapshot[]> {
|
||||
return invoke('plugin:instance|instance_list_backups', { instanceId })
|
||||
}
|
||||
|
||||
export function deleteBackup(snapshotId: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_delete_backup', { snapshotId })
|
||||
}
|
||||
|
||||
export function startBackupRestorePreview(snapshotId: string): Promise<string> {
|
||||
return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId })
|
||||
}
|
||||
|
||||
export function restoreBackup(snapshotId: string, planToken: string): Promise<string> {
|
||||
return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken })
|
||||
}
|
||||
|
||||
export function getBackupDeleteSummary(instanceId: string): Promise<BackupDeleteSummary> {
|
||||
return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId })
|
||||
}
|
||||
|
||||
export function listenBackupProgress(
|
||||
handler: (event: BackupProgressEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<BackupProgressEvent>('instance_backup_progress', (event) => handler(event.payload))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
|
||||
|
||||
export type InstanceGroupDefinition = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type InstanceGroupMembershipUpdate = {
|
||||
instance_id: string
|
||||
group_ids: string[]
|
||||
}
|
||||
|
||||
export async function list_groups(): Promise<InstanceGroupDefinition[]> {
|
||||
return await invoke('plugin:instance|instance_list_groups')
|
||||
}
|
||||
|
||||
export async function create_group(name: string): Promise<InstanceGroupDefinition> {
|
||||
return await invoke('plugin:instance|instance_create_group', { name })
|
||||
}
|
||||
|
||||
export async function rename_group(id: string, newName: string): Promise<InstanceGroupDefinition> {
|
||||
return await invoke('plugin:instance|instance_rename_group', {
|
||||
id,
|
||||
newName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function delete_group(id: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_group', { id })
|
||||
}
|
||||
|
||||
export async function set_group_order(group_ids: string[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_set_group_order', {
|
||||
groupIds: group_ids,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_group_memberships(
|
||||
updates: InstanceGroupMembershipUpdate[],
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_set_group_memberships', {
|
||||
updates,
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ContentItem,
|
||||
type ContentOwner,
|
||||
} from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
||||
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
|
||||
@@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:instance|instance_get_many', { instanceIds })
|
||||
}
|
||||
|
||||
export function adaptContentItems(items: ContentItem[]): ContentItem[] {
|
||||
return items.map((item) => {
|
||||
const embeddedMetadata = item.embedded_metadata
|
||||
if (!embeddedMetadata?.icon_path) return item
|
||||
return {
|
||||
...item,
|
||||
embedded_metadata: {
|
||||
...embeddedMetadata,
|
||||
icon_url: convertFileSrc(embeddedMetadata.icon_path),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type SyncedOption =
|
||||
| 'game_options'
|
||||
| 'command_history'
|
||||
| 'multiplayer_servers'
|
||||
| 'creative_hotbars'
|
||||
| 'screenshots'
|
||||
| 'resource_packs'
|
||||
| 'data_packs'
|
||||
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
|
||||
export type SyncedOptionCapability = {
|
||||
option: SyncedOption
|
||||
supported: boolean
|
||||
disabled_reason: string | null
|
||||
}
|
||||
export type SyncedOptionJoinPreview = { action: string }
|
||||
export type SyncedOptionsOverview = {
|
||||
global_options: GlobalSyncedOptions
|
||||
capabilities: SyncedOptionCapability[]
|
||||
}
|
||||
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_get_synced_options')
|
||||
}
|
||||
export async function get_initialized_synced_options(): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_get_initialized_synced_options')
|
||||
}
|
||||
/**
|
||||
* Every synced option is currently available on every instance. The parameter
|
||||
* stays so callers keep passing the option they are asking about, which is what
|
||||
* a real implementation will need.
|
||||
*/
|
||||
export function isSyncedOptionAvailable(_option: SyncedOption): boolean {
|
||||
return true
|
||||
}
|
||||
export async function set_global_synced_option(
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
baseInstanceId?: string | null,
|
||||
): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_set_synced_option', {
|
||||
option,
|
||||
enabled,
|
||||
baseInstanceId,
|
||||
})
|
||||
}
|
||||
export async function set_instance_synced_option(
|
||||
instanceId: string,
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
resolution?: string | null,
|
||||
): Promise<GameInstance> {
|
||||
return await invoke('plugin:instance|instance_set_instance_synced_option', {
|
||||
instanceId,
|
||||
option,
|
||||
enabled,
|
||||
resolution,
|
||||
})
|
||||
}
|
||||
export async function get_synced_options_overview(
|
||||
instanceId: string,
|
||||
): Promise<SyncedOptionsOverview> {
|
||||
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
|
||||
}
|
||||
export async function get_command_history(): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_get_synced_command_history')
|
||||
}
|
||||
export async function set_command_history(contents: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_set_synced_command_history', { contents })
|
||||
}
|
||||
export type SyncedServer = {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
accept_textures: boolean | null
|
||||
}
|
||||
export async function list_synced_servers(): Promise<SyncedServer[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_servers')
|
||||
}
|
||||
export async function update_synced_server(server: SyncedServer): Promise<void> {
|
||||
await invoke('plugin:instance|instance_update_synced_server', { server })
|
||||
}
|
||||
export async function remove_synced_server(id: string): Promise<void> {
|
||||
await invoke('plugin:instance|instance_remove_synced_server', { id })
|
||||
}
|
||||
|
||||
export async function get_projects(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
@@ -1124,3 +1222,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
|
||||
export async function allow_symlink_target(path: string): Promise<void> {
|
||||
return await invoke('allow_symlink_target', { path })
|
||||
}
|
||||
|
||||
export type ScreenshotKey = { instance_id: string; file_name: string }
|
||||
export type InstanceScreenshot = {
|
||||
id: string
|
||||
instance_id: string
|
||||
instance_name: string
|
||||
file_name: string
|
||||
created_at: string
|
||||
modified_at: number
|
||||
group_id: string | null
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
export type ScreenshotGroup = { id: string; name: string }
|
||||
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
|
||||
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
|
||||
|
||||
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
|
||||
}
|
||||
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_all_screenshots')
|
||||
}
|
||||
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_screenshots')
|
||||
}
|
||||
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshot_groups')
|
||||
}
|
||||
export async function create_screenshot_group(
|
||||
name: string,
|
||||
screenshotIds: string[],
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_create_screenshot_group', {
|
||||
name,
|
||||
ids: screenshotIds,
|
||||
})
|
||||
}
|
||||
export async function rename_screenshot_group(
|
||||
id: string,
|
||||
newName: string,
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
|
||||
}
|
||||
export async function delete_screenshot_group(id: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
|
||||
}
|
||||
export async function set_screenshot_group_memberships(
|
||||
updates: ScreenshotGroupMembershipUpdate[],
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
|
||||
}
|
||||
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
|
||||
}
|
||||
export async function save_edited_screenshot(
|
||||
key: ScreenshotKey,
|
||||
pngBytes: Uint8Array,
|
||||
mode: 'create_copy' | 'replace_edit',
|
||||
): Promise<InstanceScreenshot> {
|
||||
return await invoke('plugin:instance|instance_save_edited_screenshot', {
|
||||
key,
|
||||
bytes: Array.from(pngBytes),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
|
||||
}
|
||||
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
|
||||
}
|
||||
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_open_screenshot', { key })
|
||||
}
|
||||
export async function move_screenshots(
|
||||
keys: ScreenshotKey[],
|
||||
targetInstanceId: string,
|
||||
): Promise<ScreenshotKey[]> {
|
||||
return await invoke('plugin:instance|instance_move_screenshots', {
|
||||
keys,
|
||||
target: targetInstanceId,
|
||||
})
|
||||
}
|
||||
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
|
||||
return iconPath ? convertFileSrc(iconPath) : undefined
|
||||
}
|
||||
@@ -95,6 +95,50 @@ export async function undo_added_mod(instanceId, filename, expectedHash) {
|
||||
return await invoke('plugin:logs|logs_undo_added_mod', { instanceId, filename, expectedHash })
|
||||
}
|
||||
|
||||
export async function get_log_share_settings() {
|
||||
return await invoke('plugin:logs|logs_get_log_share_settings')
|
||||
}
|
||||
|
||||
export async function update_log_share_settings(settings) {
|
||||
return await invoke('plugin:logs|logs_update_log_share_settings', { settings })
|
||||
}
|
||||
|
||||
export async function logshare_upload_crash(instanceId) {
|
||||
return await invoke('plugin:logs|logs_logshare_upload_crash', { instanceId })
|
||||
}
|
||||
|
||||
export async function logshare_get_insights(id) {
|
||||
return await invoke('plugin:logs|logs_logshare_get_insights', { id })
|
||||
}
|
||||
|
||||
export async function logshare_analyse_crash_direct(instanceId) {
|
||||
return await invoke('plugin:logs|logs_logshare_analyse_crash_direct', { instanceId })
|
||||
}
|
||||
|
||||
export async function logshare_ai_analyze_stored(instanceId, id) {
|
||||
return await invoke('plugin:logs|logs_logshare_ai_analyze_stored', { instanceId, id })
|
||||
}
|
||||
|
||||
export async function logshare_ai_analyze_direct(instanceId) {
|
||||
return await invoke('plugin:logs|logs_logshare_ai_analyze_direct', { instanceId })
|
||||
}
|
||||
|
||||
export async function logshare_delete(id, token) {
|
||||
return await invoke('plugin:logs|logs_logshare_delete', { id, token })
|
||||
}
|
||||
|
||||
export async function list_shared_logs() {
|
||||
return await invoke('plugin:logs|logs_list_shared_logs')
|
||||
}
|
||||
|
||||
export async function record_shared_log(log) {
|
||||
return await invoke('plugin:logs|logs_record_shared_log', { log })
|
||||
}
|
||||
|
||||
export async function delete_shared_log(id, token) {
|
||||
return await invoke('plugin:logs|logs_delete_shared_log', { id, token })
|
||||
}
|
||||
|
||||
/// Export the censored files and local analysis from an instance's latest run.
|
||||
export async function export_crash_context(instanceId, instanceName) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const NAV_SHORTCUT_STORAGE_PREFIX = 'starlight-shortcut-nav-'
|
||||
|
||||
/**
|
||||
* Whether a navigation shortcut is enabled. Shortcuts are off by default;
|
||||
* each one is turned on individually from the Shortcut settings page.
|
||||
*/
|
||||
export function getNavShortcutEnabled(id: string): boolean {
|
||||
const value = localStorage.getItem(NAV_SHORTCUT_STORAGE_PREFIX + id)
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
export function setNavShortcutEnabled(id: string, enabled: boolean) {
|
||||
localStorage.setItem(NAV_SHORTCUT_STORAGE_PREFIX + id, String(enabled))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { mapBroadcastAnnouncements } from './remote-announcements.ts'
|
||||
|
||||
test('maps a broadcast entry into a remote announcement', () => {
|
||||
const result = mapBroadcastAnnouncements([
|
||||
{
|
||||
id: 1,
|
||||
title: '维护通知',
|
||||
subtitle: '今晚停机',
|
||||
content: '服务器将于今晚维护。',
|
||||
updateAt: 1791640873710,
|
||||
star: true,
|
||||
importantLevel: 9,
|
||||
source: 'SKIN',
|
||||
},
|
||||
])
|
||||
assert.equal(result?.length, 1)
|
||||
const item = result?.[0]
|
||||
assert.equal(item?.id, '1')
|
||||
assert.equal(item?.title, '维护通知')
|
||||
assert.equal(item?.summary, '今晚停机')
|
||||
assert.equal(item?.priority, 'critical')
|
||||
assert.equal(item?.pinned, true)
|
||||
assert.equal(item?.type, 'modal')
|
||||
assert.equal(item?.published_at, new Date(1791640873710).toISOString())
|
||||
assert.equal(item?.ends_at, null)
|
||||
})
|
||||
|
||||
test('empty or blank subtitle becomes null summary', () => {
|
||||
const result = mapBroadcastAnnouncements([
|
||||
{ id: 2, title: '公告', subtitle: ' ', content: '内容', updateAt: 1000 },
|
||||
])
|
||||
assert.equal(result?.[0]?.summary, null)
|
||||
})
|
||||
|
||||
test('skips entries missing required fields', () => {
|
||||
const result = mapBroadcastAnnouncements([
|
||||
{ id: 3, title: '有效', content: '内容', updateAt: 1000 },
|
||||
{ id: 4, title: '', content: '内容', updateAt: 1000 },
|
||||
{ title: '无 id', content: '内容', updateAt: 1000 },
|
||||
{ id: 5, title: '无时间', content: '内容' },
|
||||
null,
|
||||
'不是对象',
|
||||
])
|
||||
assert.equal(result?.length, 1)
|
||||
assert.equal(result?.[0]?.id, '3')
|
||||
})
|
||||
|
||||
test('clamps importantLevel and maps priority boundaries', () => {
|
||||
const result = mapBroadcastAnnouncements([
|
||||
{ id: 6, title: 'a', content: 'x', updateAt: 1000, importantLevel: 0 },
|
||||
{ id: 7, title: 'b', content: 'x', updateAt: 1000, importantLevel: 3 },
|
||||
{ id: 8, title: 'c', content: 'x', updateAt: 1000, importantLevel: 6 },
|
||||
{ id: 9, title: 'd', content: 'x', updateAt: 1000, importantLevel: 999 },
|
||||
])
|
||||
const byId = new Map(result?.map((item) => [item.id, item.priority]))
|
||||
assert.equal(byId.get('6'), 'low')
|
||||
assert.equal(byId.get('7'), 'normal')
|
||||
assert.equal(byId.get('8'), 'high')
|
||||
assert.equal(byId.get('9'), 'critical')
|
||||
})
|
||||
|
||||
test('returns null for non-array payloads', () => {
|
||||
assert.equal(mapBroadcastAnnouncements(null), null)
|
||||
assert.equal(mapBroadcastAnnouncements({ payload: [] }), null)
|
||||
assert.equal(mapBroadcastAnnouncements('nope'), null)
|
||||
})
|
||||
|
||||
test('pinned announcements sort first', () => {
|
||||
const result = mapBroadcastAnnouncements([
|
||||
{ id: 10, title: '普通', content: 'x', updateAt: 2000, importantLevel: 9 },
|
||||
{ id: 11, title: '置顶', content: 'x', updateAt: 1000, star: true },
|
||||
])
|
||||
assert.equal(result?.[0]?.id, '11')
|
||||
assert.equal(result?.[1]?.id, '10')
|
||||
})
|
||||
@@ -5,6 +5,7 @@ export type RemoteAnnouncement = {
|
||||
content: string
|
||||
type: 'modal' | 'notification'
|
||||
priority: 'low' | 'normal' | 'high' | 'critical'
|
||||
pinned?: boolean
|
||||
starts_at: string
|
||||
ends_at: string | null
|
||||
published_at: string
|
||||
@@ -32,18 +33,28 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const item = entry as Record<string, unknown>
|
||||
if (
|
||||
typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) ||
|
||||
typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 ||
|
||||
typeof item.content !== 'string' || item.content.length > 20000 ||
|
||||
typeof item.id !== 'string' ||
|
||||
item.id.length > 100 ||
|
||||
ids.has(item.id) ||
|
||||
typeof item.title !== 'string' ||
|
||||
!item.title.trim() ||
|
||||
item.title.length > 120 ||
|
||||
typeof item.content !== 'string' ||
|
||||
item.content.length > 20000 ||
|
||||
(item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) ||
|
||||
!['modal', 'notification'].includes(String(item.type)) ||
|
||||
!['low', 'normal', 'high', 'critical'].includes(String(item.priority)) ||
|
||||
typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) ||
|
||||
typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) ||
|
||||
(item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
|
||||
(item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
|
||||
typeof item.starts_at !== 'string' ||
|
||||
!Number.isFinite(Date.parse(item.starts_at)) ||
|
||||
typeof item.published_at !== 'string' ||
|
||||
!Number.isFinite(Date.parse(item.published_at)) ||
|
||||
(item.ends_at != null &&
|
||||
(typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
|
||||
(item.action_label != null &&
|
||||
(typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
|
||||
(item.action_url != null && !safeAnnouncementUrl(item.action_url))
|
||||
) continue
|
||||
)
|
||||
continue
|
||||
ids.add(item.id)
|
||||
items.push({
|
||||
...item,
|
||||
@@ -54,7 +65,12 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
|
||||
} as RemoteAnnouncement)
|
||||
}
|
||||
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
|
||||
return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at))
|
||||
return items.sort(
|
||||
(first, second) =>
|
||||
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
|
||||
rank[second.priority] - rank[first.priority] ||
|
||||
Date.parse(second.published_at) - Date.parse(first.published_at),
|
||||
)
|
||||
}
|
||||
|
||||
export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) {
|
||||
@@ -64,3 +80,76 @@ export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now())
|
||||
export function announcementKey(item: RemoteAnnouncement) {
|
||||
return item.id + ':' + item.published_at
|
||||
}
|
||||
|
||||
export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated'
|
||||
export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center'
|
||||
type BroadcastAnnouncementEntry = {
|
||||
title?: unknown
|
||||
subtitle?: unknown
|
||||
content?: unknown
|
||||
updateAt?: unknown
|
||||
star?: unknown
|
||||
importantLevel?: unknown
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
function broadcastPriority(level: number): RemoteAnnouncement['priority'] {
|
||||
if (level >= 9) return 'critical'
|
||||
if (level >= 6) return 'high'
|
||||
if (level >= 3) return 'normal'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[] | null {
|
||||
if (!Array.isArray(payload) || payload.length > 200) return null
|
||||
const items: RemoteAnnouncement[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const entry of payload) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const item = entry as BroadcastAnnouncementEntry
|
||||
if (
|
||||
typeof item.id !== 'number' ||
|
||||
!Number.isFinite(item.id) ||
|
||||
typeof item.title !== 'string' ||
|
||||
!item.title.trim() ||
|
||||
item.title.length > 120 ||
|
||||
typeof item.content !== 'string' ||
|
||||
item.content.length > 20000 ||
|
||||
typeof item.updateAt !== 'number' ||
|
||||
!Number.isFinite(item.updateAt) ||
|
||||
(item.subtitle != null && typeof item.subtitle !== 'string')
|
||||
)
|
||||
continue
|
||||
const id = String(item.id)
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const level =
|
||||
typeof item.importantLevel === 'number' && Number.isFinite(item.importantLevel)
|
||||
? Math.max(0, Math.min(10, Math.floor(item.importantLevel)))
|
||||
: 0
|
||||
const starred = item.star === true
|
||||
const published = new Date(item.updateAt).toISOString()
|
||||
const summary = typeof item.subtitle === 'string' && item.subtitle.trim() ? item.subtitle : null
|
||||
items.push({
|
||||
id,
|
||||
title: item.title,
|
||||
summary,
|
||||
content: item.content,
|
||||
type: starred || level >= 8 ? 'modal' : 'notification',
|
||||
priority: broadcastPriority(level),
|
||||
pinned: starred,
|
||||
starts_at: published,
|
||||
ends_at: null,
|
||||
published_at: published,
|
||||
action_url: null,
|
||||
action_label: null,
|
||||
})
|
||||
}
|
||||
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
|
||||
return items.sort(
|
||||
(first, second) =>
|
||||
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
|
||||
rank[second.priority] - rank[first.priority] ||
|
||||
Date.parse(second.published_at) - Date.parse(first.published_at),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const SHOW_SCROLL_TOP_STORAGE_KEY = 'starlight-show-scroll-top'
|
||||
const QUICK_SCROLL_ENABLED_STORAGE_KEY = 'starlight-quick-scroll-enabled'
|
||||
|
||||
export function getShowScrollTop(): boolean {
|
||||
const value = localStorage.getItem(SHOW_SCROLL_TOP_STORAGE_KEY)
|
||||
return value !== 'false'
|
||||
}
|
||||
|
||||
export function setShowScrollTop(show: boolean) {
|
||||
localStorage.setItem(SHOW_SCROLL_TOP_STORAGE_KEY, String(show))
|
||||
}
|
||||
|
||||
export function getQuickScrollEnabled(): boolean {
|
||||
const value = localStorage.getItem(QUICK_SCROLL_ENABLED_STORAGE_KEY)
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
export function setQuickScrollEnabled(enabled: boolean) {
|
||||
localStorage.setItem(QUICK_SCROLL_ENABLED_STORAGE_KEY, String(enabled))
|
||||
}
|
||||
@@ -151,6 +151,7 @@ export type AppSettings = {
|
||||
curseforge_source: DownloadSourceMode
|
||||
bypass_curseforge_download_restrictions: boolean
|
||||
mojang_auth_source: DownloadSourceMode
|
||||
ignore_ssl_errors: boolean
|
||||
|
||||
theme: ColorTheme
|
||||
accent_color: AccentColorSetting
|
||||
@@ -172,6 +173,9 @@ export type AppSettings = {
|
||||
home_layout: HomeLayout
|
||||
minimal_home_instance_id: string | null
|
||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||
ui_scale: number
|
||||
ui_font: string | null
|
||||
mono_font: string | null
|
||||
home_widgets: HomeDashboardConfig | null
|
||||
terracotta_public_nodes: string[]
|
||||
|
||||
@@ -213,6 +217,9 @@ type LegacyMirrorSettings = {
|
||||
|
||||
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
||||
settings.close_behavior ??= 'ask'
|
||||
settings.ui_scale ??= 100
|
||||
settings.ui_font ??= null
|
||||
settings.mono_font ??= null
|
||||
const hasLegacySettings =
|
||||
typeof settings.use_minecraft_mirror === 'boolean' &&
|
||||
typeof settings.use_modrinth_mirror === 'boolean' &&
|
||||
@@ -239,6 +246,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
|
||||
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
|
||||
settings.bypass_curseforge_download_restrictions ??= true
|
||||
settings.mojang_auth_source ??= 'auto'
|
||||
settings.ignore_ssl_errors ??= false
|
||||
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
|
||||
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
|
||||
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { defineMessages, type KeyBinding, type MessageDescriptor } from '@modrinth/ui'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
import { getLastBrowseContentProjectType, isBrowseContentProjectType } from '@/helpers/settings'
|
||||
|
||||
/** Which half of the settings page an action belongs to. */
|
||||
export type ShortcutGroupId = 'scroll' | 'nav'
|
||||
|
||||
/** Theme store fields that switch an action on. */
|
||||
export type ShortcutEnabledField =
|
||||
| 'quickScrollEnabled'
|
||||
| 'shortcutNavHome'
|
||||
| 'shortcutNavWorlds'
|
||||
| 'shortcutNavDiscover'
|
||||
| 'shortcutNavSkins'
|
||||
| 'shortcutNavMultiplayer'
|
||||
| 'shortcutNavLibrary'
|
||||
| 'shortcutNavLab'
|
||||
| 'shortcutNavDownloads'
|
||||
| 'shortcutNavCreate'
|
||||
| 'shortcutNavSettings'
|
||||
|
||||
export interface ShortcutContext {
|
||||
worldsTabEnabled: boolean
|
||||
offline: boolean
|
||||
}
|
||||
|
||||
export interface ShortcutAction {
|
||||
/** Stable id, used for the binding, the enable flag and the settings row. */
|
||||
id: string
|
||||
group: ShortcutGroupId
|
||||
enabledField: ShortcutEnabledField
|
||||
defaultBinding: KeyBinding
|
||||
label: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
/** Navigation actions know where they lead. */
|
||||
target?: (route: RouteLocationNormalizedLoaded) => string
|
||||
/** Navigation actions that are not reachable right now. */
|
||||
unavailable?: (context: ShortcutContext) => boolean
|
||||
/** Scrolling actions move the page's own scroller. */
|
||||
applyScroll?: (scroller: HTMLElement) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared here rather than assembled from the action ids, so the extractor can
|
||||
* see them; an id built at runtime is one a translation pass would drop.
|
||||
*/
|
||||
const messages = defineMessages({
|
||||
scrollTop: {
|
||||
id: 'app.shortcut-settings.home-action',
|
||||
defaultMessage: 'Scroll to the top',
|
||||
},
|
||||
scrollTopDescription: {
|
||||
id: 'app.shortcut-settings.home-action-description',
|
||||
defaultMessage: 'Moves the page you are reading to the top.',
|
||||
},
|
||||
scrollBottom: {
|
||||
id: 'app.shortcut-settings.end-action',
|
||||
defaultMessage: 'Scroll to the bottom',
|
||||
},
|
||||
scrollBottomDescription: {
|
||||
id: 'app.shortcut-settings.end-action-description',
|
||||
defaultMessage: 'Moves the page you are reading to the bottom.',
|
||||
},
|
||||
scrollUp: {
|
||||
id: 'app.shortcut-settings.page-up-action',
|
||||
defaultMessage: 'Scroll up one screen',
|
||||
},
|
||||
scrollUpDescription: {
|
||||
id: 'app.shortcut-settings.page-up-action-description',
|
||||
defaultMessage: 'Moves the page you are reading up by one screen.',
|
||||
},
|
||||
scrollDown: {
|
||||
id: 'app.shortcut-settings.page-down-action',
|
||||
defaultMessage: 'Scroll down one screen',
|
||||
},
|
||||
scrollDownDescription: {
|
||||
id: 'app.shortcut-settings.page-down-action-description',
|
||||
defaultMessage: 'Moves the page you are reading down by one screen.',
|
||||
},
|
||||
navHome: { id: 'app.shortcut-settings.nav-home', defaultMessage: 'Home' },
|
||||
navHomeDescription: {
|
||||
id: 'app.shortcut-settings.nav-home-description',
|
||||
defaultMessage: 'Jump to the Home page.',
|
||||
},
|
||||
navWorlds: { id: 'app.shortcut-settings.nav-worlds', defaultMessage: 'Worlds' },
|
||||
navWorldsDescription: {
|
||||
id: 'app.shortcut-settings.nav-worlds-description',
|
||||
defaultMessage: 'Jump to your worlds.',
|
||||
},
|
||||
navDiscover: {
|
||||
id: 'app.shortcut-settings.nav-discover',
|
||||
defaultMessage: 'Discover content',
|
||||
},
|
||||
navDiscoverDescription: {
|
||||
id: 'app.shortcut-settings.nav-discover-description',
|
||||
defaultMessage: 'Jump to browsing content.',
|
||||
},
|
||||
navSkins: { id: 'app.shortcut-settings.nav-skins', defaultMessage: 'Skin selector' },
|
||||
navSkinsDescription: {
|
||||
id: 'app.shortcut-settings.nav-skins-description',
|
||||
defaultMessage: 'Jump to the skin selector.',
|
||||
},
|
||||
navMultiplayer: { id: 'app.shortcut-settings.nav-multiplayer', defaultMessage: 'Multiplayer' },
|
||||
navMultiplayerDescription: {
|
||||
id: 'app.shortcut-settings.nav-multiplayer-description',
|
||||
defaultMessage: 'Jump to multiplayer.',
|
||||
},
|
||||
navLibrary: { id: 'app.shortcut-settings.nav-library', defaultMessage: 'Library' },
|
||||
navLibraryDescription: {
|
||||
id: 'app.shortcut-settings.nav-library-description',
|
||||
defaultMessage: 'Jump to your library.',
|
||||
},
|
||||
navLab: { id: 'app.shortcut-settings.nav-lab', defaultMessage: 'Lab' },
|
||||
navLabDescription: {
|
||||
id: 'app.shortcut-settings.nav-lab-description',
|
||||
defaultMessage: 'Jump to the Lab.',
|
||||
},
|
||||
navDownloads: { id: 'app.shortcut-settings.nav-downloads', defaultMessage: 'Downloads' },
|
||||
navDownloadsDescription: {
|
||||
id: 'app.shortcut-settings.nav-downloads-description',
|
||||
defaultMessage: 'Jump to downloads.',
|
||||
},
|
||||
navCreate: { id: 'app.shortcut-settings.nav-create', defaultMessage: 'Create new instance' },
|
||||
navCreateDescription: {
|
||||
id: 'app.shortcut-settings.nav-create-description',
|
||||
defaultMessage: 'Jump to creating a new instance.',
|
||||
},
|
||||
navSettings: { id: 'app.shortcut-settings.nav-settings', defaultMessage: 'Settings' },
|
||||
navSettingsDescription: {
|
||||
id: 'app.shortcut-settings.nav-settings-description',
|
||||
defaultMessage: 'Jump to the settings.',
|
||||
},
|
||||
})
|
||||
|
||||
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
|
||||
return { device: 'keyboard', code, mod, alt, shift }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Discover content target from the current route, so the nav
|
||||
* button and the keyboard shortcut share one source of truth.
|
||||
*/
|
||||
export function discoverContentTarget(route: RouteLocationNormalizedLoaded): string {
|
||||
const projectType = route.params.projectType
|
||||
if (
|
||||
!route.query.i &&
|
||||
!route.query.sid &&
|
||||
!route.query.wid &&
|
||||
typeof projectType === 'string' &&
|
||||
isBrowseContentProjectType(projectType)
|
||||
) {
|
||||
return `/browse/${projectType}`
|
||||
}
|
||||
|
||||
return `/browse/${getLastBrowseContentProjectType()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shortcut the launcher answers to. The binding here is the one a fresh
|
||||
* install uses; a recorded binding is stored separately and wins over it.
|
||||
*/
|
||||
export const SHORTCUT_ACTIONS: ShortcutAction[] = [
|
||||
{
|
||||
id: 'shortcutScrollTop',
|
||||
group: 'scroll',
|
||||
enabledField: 'quickScrollEnabled',
|
||||
defaultBinding: keyboard('Home'),
|
||||
label: messages.scrollTop,
|
||||
description: messages.scrollTopDescription,
|
||||
applyScroll: (scroller) => scroller.scrollTo({ top: 0, behavior: 'smooth' }),
|
||||
},
|
||||
{
|
||||
id: 'shortcutScrollBottom',
|
||||
group: 'scroll',
|
||||
enabledField: 'quickScrollEnabled',
|
||||
defaultBinding: keyboard('End'),
|
||||
label: messages.scrollBottom,
|
||||
description: messages.scrollBottomDescription,
|
||||
applyScroll: (scroller) =>
|
||||
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }),
|
||||
},
|
||||
{
|
||||
id: 'shortcutScrollUp',
|
||||
group: 'scroll',
|
||||
enabledField: 'quickScrollEnabled',
|
||||
defaultBinding: keyboard('PageUp'),
|
||||
label: messages.scrollUp,
|
||||
description: messages.scrollUpDescription,
|
||||
applyScroll: (scroller) => {
|
||||
// Immediate scrolling keeps rapid key repeats responsive.
|
||||
scroller.scrollTop = Math.max(0, scroller.scrollTop - scroller.clientHeight * 0.9)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'shortcutScrollDown',
|
||||
group: 'scroll',
|
||||
enabledField: 'quickScrollEnabled',
|
||||
defaultBinding: keyboard('PageDown'),
|
||||
label: messages.scrollDown,
|
||||
description: messages.scrollDownDescription,
|
||||
applyScroll: (scroller) => {
|
||||
scroller.scrollTop = Math.min(
|
||||
scroller.scrollHeight,
|
||||
scroller.scrollTop + scroller.clientHeight * 0.9,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavHome',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavHome',
|
||||
defaultBinding: keyboard('Digit1', true),
|
||||
label: messages.navHome,
|
||||
description: messages.navHomeDescription,
|
||||
target: () => '/',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavWorlds',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavWorlds',
|
||||
defaultBinding: keyboard('Digit2', true),
|
||||
label: messages.navWorlds,
|
||||
description: messages.navWorldsDescription,
|
||||
target: () => '/worlds',
|
||||
unavailable: (context) => !context.worldsTabEnabled,
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavDiscover',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavDiscover',
|
||||
defaultBinding: keyboard('Digit3', true),
|
||||
label: messages.navDiscover,
|
||||
description: messages.navDiscoverDescription,
|
||||
target: (route) => discoverContentTarget(route),
|
||||
unavailable: (context) => context.offline,
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavSkins',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavSkins',
|
||||
defaultBinding: keyboard('Digit4', true),
|
||||
label: messages.navSkins,
|
||||
description: messages.navSkinsDescription,
|
||||
target: () => '/skins',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavMultiplayer',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavMultiplayer',
|
||||
defaultBinding: keyboard('Digit5', true),
|
||||
label: messages.navMultiplayer,
|
||||
description: messages.navMultiplayerDescription,
|
||||
target: () => '/multiplayer',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavLibrary',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavLibrary',
|
||||
defaultBinding: keyboard('Digit6', true),
|
||||
label: messages.navLibrary,
|
||||
description: messages.navLibraryDescription,
|
||||
target: () => '/library',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavLab',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavLab',
|
||||
defaultBinding: keyboard('Digit7', true),
|
||||
label: messages.navLab,
|
||||
description: messages.navLabDescription,
|
||||
target: () => '/lab',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavDownloads',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavDownloads',
|
||||
defaultBinding: keyboard('Digit8', true),
|
||||
label: messages.navDownloads,
|
||||
description: messages.navDownloadsDescription,
|
||||
target: () => '/downloads',
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavCreate',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavCreate',
|
||||
defaultBinding: keyboard('Digit9', true),
|
||||
label: messages.navCreate,
|
||||
description: messages.navCreateDescription,
|
||||
target: () => '/create',
|
||||
unavailable: (context) => context.offline,
|
||||
},
|
||||
{
|
||||
id: 'shortcutNavSettings',
|
||||
group: 'nav',
|
||||
enabledField: 'shortcutNavSettings',
|
||||
defaultBinding: keyboard('Comma', true),
|
||||
label: messages.navSettings,
|
||||
description: messages.navSettingsDescription,
|
||||
target: () => '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export const SCROLL_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'scroll')
|
||||
export const NAV_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'nav')
|
||||
|
||||
export function shortcutAction(id: string): ShortcutAction | undefined {
|
||||
return SHORTCUT_ACTIONS.find((action) => action.id === id)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { BindingConflict, KeyBinding } from '@modrinth/ui'
|
||||
|
||||
import { SHORTCUT_ACTIONS, shortcutAction } from '@/helpers/shortcut-actions'
|
||||
|
||||
const BINDING_STORAGE_PREFIX = 'starlight-shortcut-binding-'
|
||||
|
||||
/**
|
||||
* Combinations the shell or the operating system answers to before the launcher
|
||||
* sees them. Saved shortcuts may not take these over.
|
||||
*/
|
||||
export const RESERVED_BINDINGS: BindingConflict[] = [
|
||||
{
|
||||
id: 'system-find',
|
||||
label: 'Find in page (Ctrl/Cmd + F)',
|
||||
binding: keyboard('KeyF', true),
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
id: 'system-devtools',
|
||||
label: 'Developer tools (F12)',
|
||||
binding: keyboard('F12'),
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
id: 'system-close',
|
||||
label: 'Close window (Ctrl/Cmd + W)',
|
||||
binding: keyboard('KeyW', true),
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
id: 'system-quit',
|
||||
label: 'Quit (Ctrl/Cmd + Q)',
|
||||
binding: keyboard('KeyQ', true),
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
id: 'system-reload',
|
||||
label: 'Reload (Ctrl/Cmd + R)',
|
||||
binding: keyboard('KeyR', true),
|
||||
reserved: true,
|
||||
},
|
||||
{ id: 'system-refresh', label: 'Refresh (F5)', binding: keyboard('F5'), reserved: true },
|
||||
{
|
||||
id: 'system-exit',
|
||||
label: 'Close window (Alt + F4)',
|
||||
binding: keyboard('F4', false, true),
|
||||
reserved: true,
|
||||
},
|
||||
]
|
||||
|
||||
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
|
||||
return { device: 'keyboard', code, mod, alt, shift }
|
||||
}
|
||||
|
||||
function isKeyBinding(value: unknown): value is KeyBinding {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as Partial<KeyBinding>
|
||||
return (
|
||||
(candidate.device === 'keyboard' || candidate.device === 'mouse') &&
|
||||
typeof candidate.code === 'string' &&
|
||||
candidate.code.length > 0 &&
|
||||
typeof candidate.mod === 'boolean' &&
|
||||
typeof candidate.shift === 'boolean' &&
|
||||
typeof candidate.alt === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
/** The combination recorded for an action, if it has one. */
|
||||
export function readStoredBinding(id: string): KeyBinding | null {
|
||||
const raw = localStorage.getItem(BINDING_STORAGE_PREFIX + id)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return isKeyBinding(parsed) ? parsed : null
|
||||
} catch {
|
||||
// A value that cannot be read is treated as unset, so the action keeps
|
||||
// working with its default rather than becoming unreachable.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storeBinding(id: string, binding: KeyBinding) {
|
||||
localStorage.setItem(BINDING_STORAGE_PREFIX + id, JSON.stringify(binding))
|
||||
}
|
||||
|
||||
/** Drops the recorded combination, putting the action back on its default. */
|
||||
export function clearStoredBinding(id: string) {
|
||||
if (readStoredBinding(id)) localStorage.removeItem(BINDING_STORAGE_PREFIX + id)
|
||||
}
|
||||
|
||||
/** The combination an action answers to now: the recorded one, or its default. */
|
||||
export function resolveBinding(id: string): KeyBinding | null {
|
||||
const action = shortcutAction(id)
|
||||
if (!action) return null
|
||||
return readStoredBinding(id) ?? action.defaultBinding
|
||||
}
|
||||
|
||||
export function resolveAllBindings(): Record<string, KeyBinding> {
|
||||
const bindings: Record<string, KeyBinding> = {}
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
bindings[action.id] = resolveBinding(action.id) ?? action.defaultBinding
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a new combination would collide with: the other actions, plus the
|
||||
* combinations the shell owns. `label` is what the dialog names in the error.
|
||||
*/
|
||||
export function buildConflicts(exceptId: string, label: (id: string) => string): BindingConflict[] {
|
||||
const conflicts = SHORTCUT_ACTIONS.filter((action) => action.id !== exceptId).flatMap(
|
||||
(action) => {
|
||||
const binding = resolveBinding(action.id)
|
||||
return binding ? [{ id: action.id, label: label(action.id), binding }] : []
|
||||
},
|
||||
)
|
||||
|
||||
return [...conflicts, ...RESERVED_BINDINGS]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
|
||||
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
||||
|
||||
function withStorage(run: (values: Map<string, string>) => void): void {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
})
|
||||
try {
|
||||
run(values)
|
||||
} finally {
|
||||
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
|
||||
else delete (globalThis as { localStorage?: Storage }).localStorage
|
||||
}
|
||||
}
|
||||
|
||||
test('armor preview selections survive storage round trips', () => {
|
||||
withStorage(() => {
|
||||
const config = loadSkinArmorPreview()
|
||||
config.helmet.material = 'diamond'
|
||||
config.helmet.trimPattern = 'sentry'
|
||||
config.helmet.trimMaterial = 'gold'
|
||||
saveSkinArmorPreview(config)
|
||||
|
||||
assert.deepEqual(loadSkinArmorPreview(), config)
|
||||
})
|
||||
})
|
||||
|
||||
test('invalid stored armor values fall back per slot and field', () => {
|
||||
withStorage((values) => {
|
||||
values.set(
|
||||
'axolotl:skin-armor-preview',
|
||||
JSON.stringify({
|
||||
helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' },
|
||||
boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' },
|
||||
}),
|
||||
)
|
||||
|
||||
const config = loadSkinArmorPreview()
|
||||
assert.deepEqual(config.helmet, {
|
||||
material: 'diamond',
|
||||
trimPattern: null,
|
||||
trimMaterial: 'gold',
|
||||
})
|
||||
assert.deepEqual(config.boots, {
|
||||
material: null,
|
||||
trimPattern: 'sentry',
|
||||
trimMaterial: 'iron',
|
||||
})
|
||||
assert.equal(config.chestplate.material, null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ARMOR_SLOTS,
|
||||
ARMOR_TRIM_MATERIALS,
|
||||
ARMOR_TRIM_PATTERNS,
|
||||
armorMaterialsForSlot,
|
||||
type ArmorPreviewConfig,
|
||||
createDefaultArmorPreviewConfig,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const STORAGE_KEY = 'axolotl:skin-armor-preview'
|
||||
|
||||
function includes<T extends string>(values: readonly T[], value: unknown): value is T {
|
||||
return typeof value === 'string' && values.some((item) => item === value)
|
||||
}
|
||||
|
||||
export function loadSkinArmorPreview(): ArmorPreviewConfig {
|
||||
const config = createDefaultArmorPreviewConfig()
|
||||
try {
|
||||
const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null')
|
||||
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config
|
||||
|
||||
for (const slot of ARMOR_SLOTS) {
|
||||
const piece = saved[slot]
|
||||
if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue
|
||||
if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) {
|
||||
config[slot].material = piece.material
|
||||
}
|
||||
if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) {
|
||||
config[slot].trimPattern = piece.trimPattern
|
||||
}
|
||||
if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) {
|
||||
config[slot].trimMaterial = piece.trimMaterial
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid or unavailable local storage falls back to a fresh preview.
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function saveSkinArmorPreview(config: ArmorPreviewConfig): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch {
|
||||
// The preview remains usable when local storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
|
||||
import { list_game_options_sync_sources } from './game-options'
|
||||
import {
|
||||
get_command_history,
|
||||
get_global_synced_options,
|
||||
get_initialized_synced_options,
|
||||
list_synced_servers,
|
||||
} from './instance'
|
||||
import type { GameInstance } from './types'
|
||||
|
||||
export const syncedOptionsKeys = {
|
||||
global: ['global-synced-options'] as const,
|
||||
initialized: ['initialized-synced-options'] as const,
|
||||
gameSources: ['game-options-sync-sources'] as const,
|
||||
set: ['global-synced-options', 'set'] as const,
|
||||
servers: ['synced-servers', 'list'] as const,
|
||||
commandHistory: ['synced-options', 'command-history'] as const,
|
||||
}
|
||||
|
||||
export function globalSyncedOptionsQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.global,
|
||||
queryFn: get_global_synced_options,
|
||||
})
|
||||
}
|
||||
|
||||
export function initializedSyncedOptionsQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.initialized,
|
||||
queryFn: get_initialized_synced_options,
|
||||
})
|
||||
}
|
||||
|
||||
export function gameOptionsSyncSourcesQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.gameSources,
|
||||
queryFn: list_game_options_sync_sources,
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function syncedServersQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.servers,
|
||||
queryFn: list_synced_servers,
|
||||
})
|
||||
}
|
||||
|
||||
export function commandHistoryQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.commandHistory,
|
||||
queryFn: get_command_history,
|
||||
})
|
||||
}
|
||||
|
||||
export function canSourceMultiplayerServers(instance: GameInstance): boolean {
|
||||
return !['server_project', 'server_project_modpack', 'modrinth_hosting'].includes(
|
||||
instance.link?.type ?? '',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { adaptContentItems, isSyncedOptionAvailable } from './instance'
|
||||
|
||||
export type SyncedPackType = 'resourcepack' | 'datapack'
|
||||
export type SyncedPackAction = 'enable' | 'disable'
|
||||
|
||||
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
|
||||
|
||||
export function isSyncedPackTypeAvailable(projectType: ContentItem['project_type']): boolean {
|
||||
return (
|
||||
['resourcepack', 'datapack'].includes(projectType) &&
|
||||
isSyncedOptionAvailable(projectType === 'resourcepack' ? 'resource_packs' : 'data_packs')
|
||||
)
|
||||
}
|
||||
|
||||
export interface PackSyncPreview {
|
||||
pack: ContentItem
|
||||
instances: {
|
||||
instance_id: string
|
||||
name: string
|
||||
game_version: string
|
||||
compatible: boolean
|
||||
participating: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export const syncedPackKeys = {
|
||||
all: ['synced-packs'] as const,
|
||||
list: (type: SyncedPackType) => ['synced-packs', 'list', type] as const,
|
||||
}
|
||||
|
||||
export function syncedPackQueryOptions(type: SyncedPackType) {
|
||||
return queryOptions({
|
||||
queryKey: syncedPackKeys.list(type),
|
||||
queryFn: () => list_synced_packs(type),
|
||||
enabled: isSyncedPackTypeAvailable(type),
|
||||
})
|
||||
}
|
||||
|
||||
export async function list_synced_packs(projectType: SyncedPackType): Promise<ContentItem[]> {
|
||||
const items = await invoke<ContentItem[]>('plugin:instance|instance_list_synced_packs', {
|
||||
projectType,
|
||||
})
|
||||
return adaptContentItems(items).map((item) => ({ ...item, external: !item.project }))
|
||||
}
|
||||
|
||||
export function get_pack_sync_preview(
|
||||
instanceId: string,
|
||||
projectPath: string,
|
||||
): Promise<PackSyncPreview> {
|
||||
return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export function sync_pack(instanceId: string, projectPath: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export function desync_pack(
|
||||
instanceId: string,
|
||||
packId: string,
|
||||
mode: DesyncServerMode,
|
||||
): Promise<void> {
|
||||
return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode })
|
||||
}
|
||||
|
||||
export function upload_synced_pack(
|
||||
path: string,
|
||||
projectType: SyncedPackType,
|
||||
gameVersions: string[],
|
||||
): Promise<void> {
|
||||
return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions })
|
||||
}
|
||||
|
||||
export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise<void> {
|
||||
return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled })
|
||||
}
|
||||
|
||||
export function remove_synced_pack(packId: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_remove_synced_pack', { packId })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import type { SystemFontFamily } from './font-family.ts'
|
||||
|
||||
let cached: Promise<SystemFontFamily[]> | null = null
|
||||
|
||||
/**
|
||||
* Installed families are enumerated by the host once per session; installing a
|
||||
* font while the launcher runs shows up after a restart.
|
||||
*/
|
||||
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
|
||||
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
|
||||
// A failed scan must not poison the session for a later retry.
|
||||
cached = null
|
||||
throw error
|
||||
})
|
||||
|
||||
return cached
|
||||
}
|
||||
+14
@@ -23,6 +23,17 @@ export type GameInstance = {
|
||||
loader_version?: string
|
||||
loader_components: LoaderComponent[]
|
||||
|
||||
synced_options: Record<
|
||||
| 'game_options'
|
||||
| 'command_history'
|
||||
| 'multiplayer_servers'
|
||||
| 'creative_hotbars'
|
||||
| 'screenshots'
|
||||
| 'resource_packs'
|
||||
| 'data_packs',
|
||||
boolean
|
||||
>
|
||||
|
||||
groups: string[]
|
||||
|
||||
link?: InstanceLink | null
|
||||
@@ -44,6 +55,7 @@ export type GameInstance = {
|
||||
force_fullscreen?: boolean
|
||||
maximize_window?: boolean
|
||||
game_resolution?: [number, number]
|
||||
window_title?: string | null
|
||||
launch_preparation_timeout?: number | null
|
||||
hooks: Hooks
|
||||
}
|
||||
@@ -228,6 +240,8 @@ type AppSettings = {
|
||||
force_fullscreen: boolean
|
||||
maximize_window: boolean
|
||||
game_resolution: [number, number]
|
||||
custom_window_title_enabled: boolean
|
||||
default_window_title: string
|
||||
hide_on_process_start: boolean
|
||||
enter_lightweight_mode_on_game_launch: boolean
|
||||
auto_set_java_high_performance_mode: boolean
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 启动器界面缩放(百分比)。
|
||||
* 这是纯本地的显示偏好,用 localStorage 保存即可,无需写入后端设置数据库。
|
||||
* 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
|
||||
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
|
||||
*/
|
||||
const UI_SCALE_STORAGE_KEY = 'starlight-ui-scale'
|
||||
const MIN_UI_SCALE = 50
|
||||
const MAX_UI_SCALE = 200
|
||||
const DEFAULT_UI_SCALE = 100
|
||||
@@ -15,18 +16,7 @@ function clampUiScale(value: number): number {
|
||||
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取已保存的缩放值。 */
|
||||
function readStoredUiScale(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY)
|
||||
if (raw === null) return DEFAULT_UI_SCALE
|
||||
return clampUiScale(Number(raw))
|
||||
} catch {
|
||||
return DEFAULT_UI_SCALE
|
||||
}
|
||||
}
|
||||
|
||||
export const uiScale = ref(readStoredUiScale())
|
||||
export const uiScale = ref(DEFAULT_UI_SCALE)
|
||||
|
||||
export function setUiScale(value: number): void {
|
||||
uiScale.value = clampUiScale(value)
|
||||
@@ -38,23 +28,27 @@ export function resetUiScale(): void {
|
||||
|
||||
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
|
||||
|
||||
/** 把缩放应用到文档根节点,并在变化时持久化。 */
|
||||
export function installUiScale(): void {
|
||||
const apply = (value: number) => {
|
||||
const factor = String(value / 100)
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--ui-scale', factor)
|
||||
// 用 CSS zoom 整体缩放界面(含内嵌网页),在 WebView2/WebKit 上最可靠。
|
||||
root.style.setProperty('zoom', factor)
|
||||
/**
|
||||
* 把缩放值应用到原生 webview。
|
||||
* 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
|
||||
*/
|
||||
async function applyUiScale(value: number): Promise<void> {
|
||||
const factor = value / 100
|
||||
document.documentElement.style.setProperty('--ui-scale', String(factor))
|
||||
try {
|
||||
await invoke('plugin:settings|set_ui_scale', { factor })
|
||||
} catch (error) {
|
||||
console.warn('应用界面缩放失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
apply(uiScale.value)
|
||||
/**
|
||||
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
|
||||
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
|
||||
*/
|
||||
export function installUiScale(): void {
|
||||
void applyUiScale(uiScale.value)
|
||||
watch(uiScale, (value) => {
|
||||
apply(value)
|
||||
try {
|
||||
localStorage.setItem(UI_SCALE_STORAGE_KEY, String(value))
|
||||
} catch (error) {
|
||||
console.warn('保存界面缩放失败', error)
|
||||
}
|
||||
void applyUiScale(value)
|
||||
})
|
||||
}
|
||||
@@ -81,6 +81,38 @@ export async function exportErrorLogs(
|
||||
return await invoke('plugin:utils|export_error_logs', { outputPath, errorMessage })
|
||||
}
|
||||
|
||||
export async function exportLauncherLogs({
|
||||
range = 'last2Hours',
|
||||
level = 'all',
|
||||
includeSystemInfo = true,
|
||||
includeInstanceLogs = false,
|
||||
includeCrashAnalysis = false,
|
||||
fileNamePrefix = 'Starlight Launcher logs',
|
||||
} = {}) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const outputPath = await save({
|
||||
defaultPath: `${fileNamePrefix} ${timestamp}.zip`,
|
||||
filters: [
|
||||
{
|
||||
name: 'ZIP archive',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!outputPath) return null
|
||||
|
||||
await invoke('plugin:utils|export_launcher_logs', {
|
||||
outputPath,
|
||||
range,
|
||||
level,
|
||||
includeSystemInfo,
|
||||
includeInstanceLogs,
|
||||
includeCrashAnalysis,
|
||||
})
|
||||
return outputPath
|
||||
}
|
||||
|
||||
export async function createInstanceShortcut(instanceName, instanceId, options = {}) {
|
||||
const outputPath = await save({
|
||||
defaultPath: `Modrinth - ${instanceName}`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 452 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user