Compare commits
3 Commits
main
...
b273113dfb
| Author | SHA1 | Date | |
|---|---|---|---|
| b273113dfb | |||
| fa96226beb | |||
| ccc7f7e16b |
51
Cargo.lock
generated
51
Cargo.lock
generated
@ -106,7 +106,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1431,21 +1431,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "discord-rich-presence"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"serde_repr",
|
|
||||||
"thiserror 2.0.19",
|
|
||||||
"uuid 0.8.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@ -3570,7 +3555,7 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tagptr",
|
"tagptr",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5300,7 +5285,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5904,7 +5889,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5984,7 +5969,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6022,7 +6007,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6048,7 +6033,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6454,7 +6439,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6762,7 +6747,7 @@ dependencies = [
|
|||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6833,7 +6818,6 @@ dependencies = [
|
|||||||
"data-url",
|
"data-url",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"dirs",
|
"dirs",
|
||||||
"discord-rich-presence",
|
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"dunce",
|
"dunce",
|
||||||
"either",
|
"either",
|
||||||
@ -6901,7 +6885,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
"whoami",
|
"whoami",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
@ -6967,7 +6951,7 @@ dependencies = [
|
|||||||
"trash",
|
"trash",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"zip 6.0.0",
|
"zip 6.0.0",
|
||||||
]
|
]
|
||||||
@ -7691,15 +7675,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uuid"
|
|
||||||
version = "0.8.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
@ -8868,7 +8843,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
"zbus_macros",
|
"zbus_macros",
|
||||||
|
|||||||
@ -41,7 +41,6 @@ dashmap = "6.1.0"
|
|||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
derive_more = "2.1.1"
|
derive_more = "2.1.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
discord-rich-presence = "1.0.0"
|
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
either = "1.15.0"
|
either = "1.15.0"
|
||||||
|
|||||||
@ -85,7 +85,6 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
|
|||||||
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
||||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||||
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
|
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
|
||||||
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
|
|
||||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
import NavRail from '@/components/ui/NavRail.vue'
|
import NavRail from '@/components/ui/NavRail.vue'
|
||||||
@ -98,7 +97,6 @@ import { useDropImport } from '@/composables/useDropImport'
|
|||||||
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { check_reachable } from '@/helpers/auth.js'
|
import { check_reachable } from '@/helpers/auth.js'
|
||||||
import { get_user, get_version } from '@/helpers/cache.js'
|
import { get_user, get_version } from '@/helpers/cache.js'
|
||||||
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
||||||
@ -119,16 +117,13 @@ import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
|||||||
import {
|
import {
|
||||||
get as getSettings,
|
get as getSettings,
|
||||||
getLastBrowseContentProjectType,
|
getLastBrowseContentProjectType,
|
||||||
getPrivacySettings,
|
|
||||||
getUpdateChannel,
|
getUpdateChannel,
|
||||||
getUpdatePreferences,
|
getUpdatePreferences,
|
||||||
isBrowseContentProjectType,
|
isBrowseContentProjectType,
|
||||||
type PrivacySettings,
|
|
||||||
savePrivacySettings,
|
|
||||||
set as setSettings,
|
set as setSettings,
|
||||||
} from '@/helpers/settings.ts'
|
} from '@/helpers/settings.ts'
|
||||||
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
||||||
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state'
|
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||||
import {
|
import {
|
||||||
areUpdatesEnabled,
|
areUpdatesEnabled,
|
||||||
backupAppDbForUpdate,
|
backupAppDbForUpdate,
|
||||||
@ -369,8 +364,6 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const stateInitialized = ref(false)
|
const stateInitialized = ref(false)
|
||||||
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
|
|
||||||
const privacyConsentPending = ref(false)
|
|
||||||
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
||||||
const closeChoiceOpen = ref(false)
|
const closeChoiceOpen = ref(false)
|
||||||
const closeChoiceRemember = ref(false)
|
const closeChoiceRemember = ref(false)
|
||||||
@ -765,9 +758,9 @@ const messages = defineMessages({
|
|||||||
id: 'app.account.signed-in-as',
|
id: 'app.account.signed-in-as',
|
||||||
defaultMessage: 'Signed in as',
|
defaultMessage: 'Signed in as',
|
||||||
},
|
},
|
||||||
playingAs: {
|
userInformation: {
|
||||||
id: 'app.minecraft.playing-as',
|
id: 'app.minecraft.user-information',
|
||||||
defaultMessage: 'Playing as',
|
defaultMessage: 'User information',
|
||||||
},
|
},
|
||||||
collapseSidebar: {
|
collapseSidebar: {
|
||||||
id: 'app.sidebar.collapse',
|
id: 'app.sidebar.collapse',
|
||||||
@ -1055,9 +1048,6 @@ async function setupApp() {
|
|||||||
theme,
|
theme,
|
||||||
accent_color,
|
accent_color,
|
||||||
locale,
|
locale,
|
||||||
telemetry,
|
|
||||||
telemetry_consent_version,
|
|
||||||
discord_rpc,
|
|
||||||
collapsed_navigation,
|
collapsed_navigation,
|
||||||
hide_nametag_skins_page,
|
hide_nametag_skins_page,
|
||||||
advanced_rendering,
|
advanced_rendering,
|
||||||
@ -1101,7 +1091,6 @@ async function setupApp() {
|
|||||||
const dev = await isDev()
|
const dev = await isDev()
|
||||||
isDevEnvironment.value = dev
|
isDevEnvironment.value = dev
|
||||||
if (!onboarded && route.path !== '/') await router.replace('/')
|
if (!onboarded && route.path !== '/') await router.replace('/')
|
||||||
privacyConsentPending.value = telemetry_consent_version < 1
|
|
||||||
showOnboarding.value = false
|
showOnboarding.value = false
|
||||||
onboardingSettings.value = initialSettings
|
onboardingSettings.value = initialSettings
|
||||||
|
|
||||||
@ -1132,16 +1121,7 @@ async function setupApp() {
|
|||||||
themeStore.devMode = developer_mode
|
themeStore.devMode = developer_mode
|
||||||
themeStore.featureFlags = feature_flags
|
themeStore.featureFlags = feature_flags
|
||||||
stateInitialized.value = true
|
stateInitialized.value = true
|
||||||
if (privacyConsentPending.value) {
|
|
||||||
await nextTick()
|
|
||||||
privacyConsentModal.value?.show({
|
|
||||||
telemetry,
|
|
||||||
discord_rpc,
|
|
||||||
consent_version: telemetry_consent_version,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showOnboarding.value = !onboarded
|
showOnboarding.value = !onboarded
|
||||||
}
|
|
||||||
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
||||||
|
|
||||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||||
@ -1274,42 +1254,7 @@ async function closeOnboardingSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleStartupDialogs() {
|
async function scheduleStartupDialogs() {
|
||||||
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return
|
if (!stateInitialized.value || showOnboarding.value) return
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
|
|
||||||
privacyConsentPending.value = false
|
|
||||||
if (onboardingSettings.value) {
|
|
||||||
onboardingSettings.value.telemetry = privacy.telemetry
|
|
||||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
|
||||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
|
||||||
}
|
|
||||||
if (!onboardingSettings.value?.onboarded) {
|
|
||||||
startOnboarding('main')
|
|
||||||
} else {
|
|
||||||
await scheduleStartupDialogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewPrivacyConsentModal() {
|
|
||||||
try {
|
|
||||||
const current = await getPrivacySettings()
|
|
||||||
const privacy = await savePrivacySettings({
|
|
||||||
telemetry: false,
|
|
||||||
discord_rpc: current.discord_rpc,
|
|
||||||
consent_version: 0,
|
|
||||||
})
|
|
||||||
privacyConsentPending.value = true
|
|
||||||
if (onboardingSettings.value) {
|
|
||||||
onboardingSettings.value.telemetry = privacy.telemetry
|
|
||||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
|
||||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
privacyConsentModal.value?.show(privacy)
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provide('replayOnboarding', replayOnboarding)
|
provide('replayOnboarding', replayOnboarding)
|
||||||
@ -1320,7 +1265,6 @@ provide(
|
|||||||
)
|
)
|
||||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||||
provide('showLauncherPopup', (_request: unknown) => {})
|
provide('showLauncherPopup', (_request: unknown) => {})
|
||||||
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
|
|
||||||
|
|
||||||
const stateFailed = ref(false)
|
const stateFailed = ref(false)
|
||||||
stateInitialization
|
stateInitialization
|
||||||
@ -1429,9 +1373,6 @@ loading.setEnabled(false)
|
|||||||
let initialLoadToken = loading.begin()
|
let initialLoadToken = loading.begin()
|
||||||
let routerToken = null
|
let routerToken = null
|
||||||
let suspenseToken = null
|
let suspenseToken = null
|
||||||
let lastDiscordActivity = null
|
|
||||||
let discordActivityUpdate = Promise.resolve()
|
|
||||||
|
|
||||||
let suspensePending = false
|
let suspensePending = false
|
||||||
|
|
||||||
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
||||||
@ -1447,31 +1388,11 @@ router.beforeEach(() => {
|
|||||||
routerToken = loading.begin()
|
routerToken = loading.begin()
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
|
|
||||||
const activity =
|
|
||||||
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
|
|
||||||
if (activity === lastDiscordActivity) return
|
|
||||||
|
|
||||||
lastDiscordActivity = activity
|
|
||||||
discordActivityUpdate = discordActivityUpdate
|
|
||||||
.then(() => set_discord_activity(activity))
|
|
||||||
.catch((error) => {
|
|
||||||
if (lastDiscordActivity === activity) lastDiscordActivity = null
|
|
||||||
console.error('Failed to update Discord activity', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.afterEach((to, from, failure) => {
|
router.afterEach((to, from, failure) => {
|
||||||
hideAllPoppers()
|
hideAllPoppers()
|
||||||
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
||||||
trackEvent('PageView', {
|
|
||||||
path: to.path,
|
|
||||||
fromPath: from.path,
|
|
||||||
failed: failure,
|
|
||||||
})
|
|
||||||
if (!failure) {
|
if (!failure) {
|
||||||
void directLinkSync?.()
|
void directLinkSync?.()
|
||||||
if (stateInitialized.value) syncDiscordActivity(to)
|
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!suspensePending && stateInitialized.value) {
|
if (!suspensePending && stateInitialized.value) {
|
||||||
@ -1508,7 +1429,6 @@ watch(
|
|||||||
stateInitialized,
|
stateInitialized,
|
||||||
(ready) => {
|
(ready) => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
syncDiscordActivity(router.currentRoute.value)
|
|
||||||
if (initialLoadToken) {
|
if (initialLoadToken) {
|
||||||
loading.end(initialLoadToken)
|
loading.end(initialLoadToken)
|
||||||
initialLoadToken = null
|
initialLoadToken = null
|
||||||
@ -1624,7 +1544,6 @@ const dropImport = useDropImport({
|
|||||||
onSkinsPage,
|
onSkinsPage,
|
||||||
onSchematicWorkshopPage,
|
onSchematicWorkshopPage,
|
||||||
isSchematicFile,
|
isSchematicFile,
|
||||||
trackEvent,
|
|
||||||
router,
|
router,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1836,9 +1755,6 @@ async function handleCommand(e) {
|
|||||||
} else {
|
} else {
|
||||||
await install_create_modpack_instance(location).catch(handleError)
|
await install_create_modpack_instance(location).catch(handleError)
|
||||||
}
|
}
|
||||||
trackEvent('InstanceCreate', {
|
|
||||||
source: 'CreationModalFileDrop',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else if (e.event === 'LaunchInstance') {
|
} else if (e.event === 'LaunchInstance') {
|
||||||
const instance = await getInstance(e.id).catch(() => null)
|
const instance = await getInstance(e.id).catch(() => null)
|
||||||
@ -2553,7 +2469,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
<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 class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||||
<h3 class="text-base text-primary font-medium m-0">
|
<h3 class="text-base text-primary font-medium m-0">
|
||||||
{{ formatMessage(messages.playingAs) }}
|
{{ formatMessage(messages.userInformation) }}
|
||||||
</h3>
|
</h3>
|
||||||
<suspense>
|
<suspense>
|
||||||
<AccountsCard ref="accounts" />
|
<AccountsCard ref="accounts" />
|
||||||
@ -2577,7 +2493,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
/>
|
/>
|
||||||
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
||||||
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
||||||
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
|
|
||||||
<NewModal
|
<NewModal
|
||||||
ref="closeChoiceModal"
|
ref="closeChoiceModal"
|
||||||
:header="formatMessage(messages.closeLauncherTitle)"
|
:header="formatMessage(messages.closeLauncherTitle)"
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
|||||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { install_duplicate_instance } from '@/helpers/install'
|
import { install_duplicate_instance } from '@/helpers/install'
|
||||||
import { kill, remove, run } from '@/helpers/instance'
|
import { kill, remove, run } from '@/helpers/instance'
|
||||||
import { get_by_instance_id } from '@/helpers/process.js'
|
import { get_by_instance_id } from '@/helpers/process.js'
|
||||||
@ -158,17 +157,9 @@ const handleOptionsClick = async (args) => {
|
|||||||
})
|
})
|
||||||
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
||||||
})
|
})
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: args.item.loader,
|
|
||||||
game_version: args.item.game_version,
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
case 'stop':
|
case 'stop':
|
||||||
await kill(args.item.id).catch(handleError)
|
await kill(args.item.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: args.item.loader,
|
|
||||||
game_version: args.item.game_version,
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
case 'add_content':
|
case 'add_content':
|
||||||
await router.push({
|
await router.push({
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
type DailyPlaytime,
|
type DailyPlaytime,
|
||||||
type DailyPlaytimeEntry,
|
type DailyPlaytimeEntry,
|
||||||
@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeCalendar',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
|
|
||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeCalendar',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
|||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -126,11 +125,6 @@ async function playInstance() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeMinimal',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -149,11 +143,6 @@ async function stopInstance() {
|
|||||||
|
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
running.value = false
|
running.value = false
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeMinimal',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installInstance() {
|
async function installInstance() {
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
|
|||||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill } from '@/helpers/instance'
|
import { kill } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await start_join_server(world.instance_id, world.address)
|
await start_join_server(world.instance_id, world.address)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedServer',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
|||||||
|
|
||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedServer',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
try {
|
try {
|
||||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||||
playingWorldKey.value = key
|
playingWorldKey.value = key
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
playingWorldKey.value = null
|
playingWorldKey.value = null
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
|
|||||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -117,11 +116,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||||
}
|
}
|
||||||
playingWorldKey.value = key
|
playingWorldKey.value = key
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -136,11 +130,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -153,11 +142,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
playingWorldKey.value = null
|
playingWorldKey.value = null
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
|
|||||||
starting.value = true
|
starting.value = true
|
||||||
try {
|
try {
|
||||||
await run(targetInstance.id)
|
await run(targetInstance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: targetInstance.loader,
|
|
||||||
game_version: targetInstance.game_version,
|
|
||||||
source: 'HomeInstanceWidget',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: targetInstance.id,
|
instance_id: targetInstance.id,
|
||||||
@ -230,11 +224,6 @@ async function playWorld() {
|
|||||||
} else {
|
} else {
|
||||||
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
||||||
}
|
}
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: 'HomeShortcutWidget',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.value.id,
|
instance_id: instance.value.id,
|
||||||
|
|||||||
@ -1,4 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
|
||||||
|
<Avatar :src="axolotlLogo" size="36px" />
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
|
||||||
|
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.skinSiteChecking) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.skinSiteSyncError) }}
|
||||||
|
</p>
|
||||||
|
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||||||
|
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
|
<LogInIcon />
|
||||||
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
<div
|
<div
|
||||||
v-if="offline"
|
v-if="offline"
|
||||||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
||||||
@ -17,18 +36,24 @@
|
|||||||
v-if="accounts.length === 0"
|
v-if="accounts.length === 0"
|
||||||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||||
>
|
>
|
||||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||||||
<ButtonStyled v-if="!offline" color="brand">
|
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
<LogInIcon v-if="!loginDisabled" />
|
<LogInIcon v-if="!loginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
{{ formatMessage(messages.signInToMinecraft) }}
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="!offline && skinSiteUser">
|
||||||
|
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="!offline">
|
<ButtonStyled v-if="!offline">
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -137,15 +162,15 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col gap-2 px-2 pt-2">
|
<div class="flex flex-col gap-2 px-2 pt-2">
|
||||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||||
<button :disabled="loginDisabled" @click="login()">
|
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -262,6 +287,7 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
|
Checkbox,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@ -272,15 +298,15 @@ import { useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import axolotlLogo from '@/assets/netherstar.png'
|
import axolotlLogo from '@/assets/netherstar.png'
|
||||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
begin_yggdrasil_login,
|
begin_yggdrasil_login,
|
||||||
delete_yggdrasil_password,
|
delete_yggdrasil_password,
|
||||||
@ -299,12 +325,22 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
import { useTheming } from '@/store/state'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const themeStore = useTheming()
|
||||||
|
|
||||||
|
async function goToSkinSiteLogin() {
|
||||||
|
openSkinSiteLogin()
|
||||||
|
// The login action must reveal the iframe even if Minimal Home was selected.
|
||||||
|
themeStore.homeLayout = 'standard'
|
||||||
|
await router.push('/').catch(handleError)
|
||||||
|
}
|
||||||
const refreshingNetwork = ref(false)
|
const refreshingNetwork = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -678,7 +714,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
|||||||
loginDisabled.value = true
|
loginDisabled.value = true
|
||||||
try {
|
try {
|
||||||
await setAccount(account)
|
await setAccount(account)
|
||||||
trackEvent('AccountLogIn')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSevereError(error)
|
handleSevereError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -833,7 +868,6 @@ async function addYggdrasilAccount() {
|
|||||||
await persistYggdrasilPasswordPreference()
|
await persistYggdrasilPasswordPreference()
|
||||||
yggdrasilAccountModal.value?.hide()
|
yggdrasilAccountModal.value?.hide()
|
||||||
await setAccount(result.credentials)
|
await setAccount(result.credentials)
|
||||||
trackEvent('YggdrasilAccountAdd')
|
|
||||||
} else {
|
} else {
|
||||||
pendingYggdrasilFlowId.value = result.flow_id
|
pendingYggdrasilFlowId.value = result.flow_id
|
||||||
pendingYggdrasilProfiles.value = result.profiles
|
pendingYggdrasilProfiles.value = result.profiles
|
||||||
@ -859,7 +893,6 @@ async function selectYggdrasilProfile(profileId: string) {
|
|||||||
await persistYggdrasilPasswordPreference()
|
await persistYggdrasilPasswordPreference()
|
||||||
yggdrasilProfileModal.value?.hide()
|
yggdrasilProfileModal.value?.hide()
|
||||||
await setAccount(account)
|
await setAccount(account)
|
||||||
trackEvent('YggdrasilAccountAdd')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error as Error)
|
handleError(error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -875,7 +908,6 @@ async function logout(account: MinecraftCredential) {
|
|||||||
} else {
|
} else {
|
||||||
notifyAccountChange()
|
notifyAccountChange()
|
||||||
}
|
}
|
||||||
trackEvent('AccountLogOut')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyAccountUuid(account: MinecraftCredential) {
|
async function copyAccountUuid(account: MinecraftCredential) {
|
||||||
@ -902,6 +934,22 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
skinSiteSignedIn: {
|
||||||
|
id: 'minecraft-account.skin-site.signed-in',
|
||||||
|
defaultMessage: 'Signed in to StarLight Skin Site',
|
||||||
|
},
|
||||||
|
skinSiteChecking: {
|
||||||
|
id: 'minecraft-account.skin-site.checking',
|
||||||
|
defaultMessage: 'Checking skin site session…',
|
||||||
|
},
|
||||||
|
skinSiteSyncError: {
|
||||||
|
id: 'minecraft-account.skin-site.sync-error',
|
||||||
|
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||||||
|
},
|
||||||
|
addSkinGameAccount: {
|
||||||
|
id: 'minecraft-account.add-skin-game-account',
|
||||||
|
defaultMessage: 'Add skin site game account',
|
||||||
|
},
|
||||||
offlineMode: {
|
offlineMode: {
|
||||||
id: 'minecraft-account.offline-mode',
|
id: 'minecraft-account.offline-mode',
|
||||||
defaultMessage: 'Offline mode',
|
defaultMessage: 'Offline mode',
|
||||||
@ -921,11 +969,11 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
addMicrosoftAccount: {
|
addMicrosoftAccount: {
|
||||||
id: 'minecraft-account.add-microsoft-account',
|
id: 'minecraft-account.add-microsoft-account',
|
||||||
defaultMessage: 'Add Microsoft account',
|
defaultMessage: 'Add your own Minecraft account',
|
||||||
},
|
},
|
||||||
addThirdPartyAccount: {
|
signInToStarlight: {
|
||||||
id: 'minecraft-account.add-third-party-account',
|
id: 'minecraft-account.sign-in-starlight',
|
||||||
defaultMessage: 'Add third-party account',
|
defaultMessage: 'Sign in to StarLight Skin Site',
|
||||||
},
|
},
|
||||||
thirdPartyAccount: {
|
thirdPartyAccount: {
|
||||||
id: 'minecraft-account.third-party-account',
|
id: 'minecraft-account.third-party-account',
|
||||||
@ -1069,10 +1117,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.label',
|
id: 'minecraft-account.label',
|
||||||
defaultMessage: 'Minecraft account',
|
defaultMessage: 'Minecraft account',
|
||||||
},
|
},
|
||||||
signInToMinecraft: {
|
|
||||||
id: 'minecraft-account.sign-in',
|
|
||||||
defaultMessage: 'Sign in to Minecraft',
|
|
||||||
},
|
|
||||||
loginTrouble: {
|
loginTrouble: {
|
||||||
id: 'minecraft-login.trouble',
|
id: 'minecraft-login.trouble',
|
||||||
defaultMessage: 'Having trouble?',
|
defaultMessage: 'Having trouble?',
|
||||||
|
|||||||
@ -212,7 +212,6 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { loading_listener, process_listener } from '@/helpers/events'
|
import { loading_listener, process_listener } from '@/helpers/events'
|
||||||
import { get_many as getInstances } from '@/helpers/instance'
|
import { get_many as getInstances } from '@/helpers/instance'
|
||||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||||
@ -437,11 +436,6 @@ const stop = async (process: RunningProcess) => {
|
|||||||
try {
|
try {
|
||||||
await killProcess(process.uuid).catch(handleError)
|
await killProcess(process.uuid).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: process.instance.loader,
|
|
||||||
game_version: process.instance.game_version,
|
|
||||||
source: 'AppBar',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
|
|||||||
import { ChatIcon } from '@/assets/icons'
|
import { ChatIcon } from '@/assets/icons'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { AxolotlBrandConfig } from '@/config'
|
import { AxolotlBrandConfig } from '@/config'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||||
import { install_existing_instance } from '@/helpers/install'
|
import { install_existing_instance } from '@/helpers/install'
|
||||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||||
@ -241,7 +240,6 @@ async function loginMinecraft() {
|
|||||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
|
||||||
loadingMinecraft.value = false
|
loadingMinecraft.value = false
|
||||||
errorModal.value.hide()
|
errorModal.value.hide()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { useRouter } from 'vue-router'
|
|||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -121,11 +120,6 @@ const play = async (e, context) => {
|
|||||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -136,11 +130,6 @@ const stop = async (e, context) => {
|
|||||||
|
|
||||||
await kill(props.instance.id).catch(handleError)
|
await kill(props.instance.id).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const repair = async (e) => {
|
const repair = async (e) => {
|
||||||
|
|||||||
@ -59,7 +59,6 @@ import {
|
|||||||
import { onUnmounted, ref } from 'vue'
|
import { onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { java_discovery_listener } from '@/helpers/events'
|
import { java_discovery_listener } from '@/helpers/events'
|
||||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||||
|
|
||||||
@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
|
|||||||
function setJavaInstall(javaInstall) {
|
function setJavaInstall(javaInstall) {
|
||||||
emit('submit', javaInstall)
|
emit('submit', javaInstall)
|
||||||
detectJavaModal.value.hide()
|
detectJavaModal.value.hide()
|
||||||
trackEvent('JavaAutoDetect', {
|
|
||||||
path: javaInstall.path,
|
|
||||||
version: javaInstall.version,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||||
import useJavaTest from '@/composables/useJavaTest'
|
import useJavaTest from '@/composables/useJavaTest'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
|
|||||||
let hasInitialized = false
|
let hasInitialized = false
|
||||||
|
|
||||||
async function runTest(path) {
|
async function runTest(path) {
|
||||||
await testJavaInstallation(path, testVersion.value, true)
|
await testJavaInstallation(path, testVersion.value)
|
||||||
if (props.version != null) {
|
if (props.version != null) {
|
||||||
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
|
await recommendedJavaTest.testJavaInstallation(path, props.version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,9 +211,9 @@ watch(
|
|||||||
(newPath) => {
|
(newPath) => {
|
||||||
if (newPath) {
|
if (newPath) {
|
||||||
if (!hasInitialized) {
|
if (!hasInitialized) {
|
||||||
testJavaInstallation(newPath, testVersion.value, false)
|
testJavaInstallation(newPath, testVersion.value)
|
||||||
if (props.version != null) {
|
if (props.version != null) {
|
||||||
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
|
recommendedJavaTest.testJavaInstallation(newPath, props.version)
|
||||||
}
|
}
|
||||||
hasInitialized = true
|
hasInitialized = true
|
||||||
} else {
|
} else {
|
||||||
@ -242,9 +241,6 @@ async function handleJavaFileInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('JavaManualSelect', {
|
|
||||||
version: props.version,
|
|
||||||
})
|
|
||||||
|
|
||||||
commitSelection(result)
|
commitSelection(result)
|
||||||
}
|
}
|
||||||
@ -279,7 +275,6 @@ async function reinstallJava() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('JavaReInstall', { path: path, version: props.version })
|
|
||||||
commitSelection(result)
|
commitSelection(result)
|
||||||
runTest(result.path)
|
runTest(result.path)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveSkinSiteSession,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteFrameUrl,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
frameTitle: {
|
||||||
|
id: 'app.starlight-skin.frame-title',
|
||||||
|
defaultMessage: 'StarLight Skin Site',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const frame = ref<HTMLIFrameElement>()
|
||||||
|
let lastMessage = 0
|
||||||
|
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
frame.value?.contentWindow?.postMessage(
|
||||||
|
{ type: 'starlight-skin-session-connect' },
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receive(event: MessageEvent) {
|
||||||
|
if (receiveSkinSiteSession(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('message', receive)
|
||||||
|
connect()
|
||||||
|
expiryTimer = setInterval(() => {
|
||||||
|
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
||||||
|
}, 10_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('message', receive)
|
||||||
|
clearInterval(expiryTimer)
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
ref="frame"
|
||||||
|
:src="skinSiteFrameUrl"
|
||||||
|
:title="formatMessage(messages.frameTitle)"
|
||||||
|
class="block h-full min-h-0 w-full border-0"
|
||||||
|
@load="connect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Avatar } from '@modrinth/ui'
|
||||||
|
import { onScopeDispose, ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ src: string; name: string; href?: string }>()
|
||||||
|
const emit = defineEmits<{ activate: [] }>()
|
||||||
|
const holding = ref(false)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let origin = { x: 0, y: 0 }
|
||||||
|
let suppressClick = false
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
holding.value = false
|
||||||
|
}
|
||||||
|
function begin() {
|
||||||
|
cancel()
|
||||||
|
suppressClick = false
|
||||||
|
holding.value = true
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cancel()
|
||||||
|
suppressClick = true
|
||||||
|
emit('activate')
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
function pointerDown(event: PointerEvent) {
|
||||||
|
if (event.button !== 0 || !event.isPrimary) return
|
||||||
|
origin = { x: event.clientX, y: event.clientY }
|
||||||
|
begin()
|
||||||
|
}
|
||||||
|
function pointerMove(event: PointerEvent) {
|
||||||
|
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
|
||||||
|
}
|
||||||
|
function click(event: MouseEvent) {
|
||||||
|
if (!suppressClick) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
suppressClick = false
|
||||||
|
}
|
||||||
|
function keydown(event: KeyboardEvent) {
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!event.repeat) begin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('blur', cancel)
|
||||||
|
onScopeDispose(() => {
|
||||||
|
cancel()
|
||||||
|
window.removeEventListener('blur', cancel)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mine-avatar"
|
||||||
|
:class="{ holding }"
|
||||||
|
@pointerdown="pointerDown"
|
||||||
|
@pointermove="pointerMove"
|
||||||
|
@pointerup="cancel"
|
||||||
|
@pointercancel="cancel"
|
||||||
|
@pointerleave="cancel"
|
||||||
|
@blur="cancel"
|
||||||
|
@click="click"
|
||||||
|
@contextmenu.prevent
|
||||||
|
@dragstart.prevent
|
||||||
|
@keydown="keydown"
|
||||||
|
@keyup.space.prevent="cancel"
|
||||||
|
>
|
||||||
|
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.mine-avatar.holding {
|
||||||
|
outline: 2px solid var(--color-brand);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.mine-avatar:focus-visible {
|
||||||
|
outline: 2px solid var(--color-contrast);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,323 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { Puzzle } from './engine'
|
||||||
|
import { messages } from './messages'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
puzzle: Puzzle
|
||||||
|
revealed: boolean[]
|
||||||
|
selected: number
|
||||||
|
playing: boolean
|
||||||
|
lost: boolean
|
||||||
|
mistake: number
|
||||||
|
zoom: number
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
paint: [index: number]
|
||||||
|
'update:zoom': [value: number]
|
||||||
|
}>()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const viewport = ref<HTMLElement>()
|
||||||
|
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
|
||||||
|
const large = computed(() => props.puzzle.difficulty !== 'easy')
|
||||||
|
const visibleCells = computed(() =>
|
||||||
|
props.puzzle.answer.flatMap((color, i) =>
|
||||||
|
props.revealed[i] || props.lost ? [{ color, i }] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
const size = props.puzzle.size
|
||||||
|
view.value = {
|
||||||
|
left: (el.scrollLeft / el.scrollWidth) * size,
|
||||||
|
top: (el.scrollTop / el.scrollHeight) * size,
|
||||||
|
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
|
||||||
|
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setView(left: number, top: number) {
|
||||||
|
await nextTick()
|
||||||
|
viewport.value?.scrollTo(left, top)
|
||||||
|
updateView()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function centerFirstClue() {
|
||||||
|
await nextTick()
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el || !large.value) return setView(0, 0)
|
||||||
|
const i = props.revealed.findIndex(Boolean)
|
||||||
|
await setView(
|
||||||
|
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
|
||||||
|
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(event: MouseEvent) {
|
||||||
|
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
void setView(
|
||||||
|
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
|
||||||
|
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeZoom(value: number) {
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
|
||||||
|
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
|
||||||
|
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
|
||||||
|
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
|
||||||
|
emit('update:zoom', zoom)
|
||||||
|
await setView(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
if (!large.value || !event.ctrlKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(index: number) {
|
||||||
|
const values = {
|
||||||
|
row: Math.floor(index / props.puzzle.size) + 1,
|
||||||
|
column: (index % props.puzzle.size) + 1,
|
||||||
|
}
|
||||||
|
return props.revealed[index] || props.lost
|
||||||
|
? formatMessage(messages.revealed, {
|
||||||
|
...values,
|
||||||
|
color: letter(props.puzzle.answer[index]),
|
||||||
|
number: props.puzzle.numbers[index],
|
||||||
|
})
|
||||||
|
: formatMessage(messages.hidden, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new ResizeObserver(updateView)
|
||||||
|
if (viewport.value) observer.observe(viewport.value)
|
||||||
|
})
|
||||||
|
onScopeDispose(() => observer?.disconnect())
|
||||||
|
defineExpose({
|
||||||
|
centerFirstClue,
|
||||||
|
setView,
|
||||||
|
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mine-navigation" :class="{ 'mine-large': large }">
|
||||||
|
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
|
||||||
|
<div
|
||||||
|
class="mine-grid"
|
||||||
|
:class="{ 'mine-grid-small': !large }"
|
||||||
|
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
|
||||||
|
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(_, i) in puzzle.answer"
|
||||||
|
:key="i"
|
||||||
|
type="button"
|
||||||
|
class="mine-cell"
|
||||||
|
:class="{
|
||||||
|
'mine-open': revealed[i] || lost,
|
||||||
|
'mine-answer': lost && !revealed[i],
|
||||||
|
'mine-mistake': mistake === i,
|
||||||
|
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
|
||||||
|
}"
|
||||||
|
:style="
|
||||||
|
revealed[i] || lost
|
||||||
|
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:disabled="!playing"
|
||||||
|
:aria-label="label(i)"
|
||||||
|
@click="emit('paint', i)"
|
||||||
|
>
|
||||||
|
<template v-if="revealed[i] || lost">
|
||||||
|
<span>{{ puzzle.numbers[i] }}</span
|
||||||
|
><small>{{ letter(puzzle.answer[i]) }}</small>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="large" class="mine-overview">
|
||||||
|
<svg
|
||||||
|
class="mine-map"
|
||||||
|
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
|
||||||
|
role="img"
|
||||||
|
:aria-label="formatMessage(messages.map)"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
<rect width="100%" height="100%" fill="var(--surface-1)" />
|
||||||
|
<rect
|
||||||
|
v-for="cell in visibleCells"
|
||||||
|
:key="cell.i"
|
||||||
|
:x="cell.i % puzzle.size"
|
||||||
|
:y="Math.floor(cell.i / puzzle.size)"
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
:fill="`var(--mine-color-${cell.color})`"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
:x="view.left"
|
||||||
|
:y="view.top"
|
||||||
|
:width="view.width"
|
||||||
|
:height="view.height"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-contrast)"
|
||||||
|
stroke-width="2"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="mine-zoom">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="zoom <= 0.7"
|
||||||
|
:aria-label="formatMessage(messages.zoomOut)"
|
||||||
|
@click="changeZoom(zoom - 0.1)"
|
||||||
|
>−</Button
|
||||||
|
>
|
||||||
|
<span>{{ Math.round(zoom * 100) }}%</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="zoom >= 1.6"
|
||||||
|
:aria-label="formatMessage(messages.zoomIn)"
|
||||||
|
@click="changeZoom(zoom + 0.1)"
|
||||||
|
>+</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine-navigation {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
}
|
||||||
|
.mine-large {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 7rem;
|
||||||
|
}
|
||||||
|
.mine-viewport {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: min(52vh, 32rem);
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.mine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
|
||||||
|
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
|
||||||
|
grid-auto-rows: var(--mine-cell-size);
|
||||||
|
gap: 3px;
|
||||||
|
width: max-content;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.mine-grid-small {
|
||||||
|
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
width: min(100%, 26rem, 48vh);
|
||||||
|
margin: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mine-cell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-4);
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.mine-cell:hover:not(:disabled) {
|
||||||
|
background: var(--surface-5);
|
||||||
|
border-color: var(--mine-ink);
|
||||||
|
}
|
||||||
|
.mine-cell:focus-visible {
|
||||||
|
outline: 2px solid var(--color-contrast);
|
||||||
|
outline-offset: -3px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.mine-open {
|
||||||
|
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
|
||||||
|
border-color: var(--mine-cell-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mine-open:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
|
||||||
|
}
|
||||||
|
.mine-sampled {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
|
||||||
|
}
|
||||||
|
.mine-cell small {
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 1px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mine-answer {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.mine-cell:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.mine-mistake {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 3px solid var(--color-red);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
.mine-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mine-map {
|
||||||
|
display: block;
|
||||||
|
width: 7rem;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid var(--surface-5);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.mine-zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.mine-large {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.mine-overview {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mine-map {
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,482 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import './palette.css'
|
||||||
|
|
||||||
|
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import ColorMineBoard from './ColorMineBoard.vue'
|
||||||
|
import { type Difficulty, LEVELS, type Puzzle } from './engine'
|
||||||
|
import { messages } from './messages'
|
||||||
|
import {
|
||||||
|
parseSave,
|
||||||
|
readBest,
|
||||||
|
recordBest,
|
||||||
|
SAVE_KEY,
|
||||||
|
type SavedGame,
|
||||||
|
saveGame,
|
||||||
|
validateResume,
|
||||||
|
} from './storage'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const board = ref<InstanceType<typeof ColorMineBoard>>()
|
||||||
|
const prompt = ref<HTMLElement>()
|
||||||
|
const puzzle = shallowRef<Puzzle>()
|
||||||
|
const revealed = ref<boolean[]>([])
|
||||||
|
const selected = ref(-1)
|
||||||
|
const mistake = ref(-1)
|
||||||
|
const elapsed = ref(0)
|
||||||
|
const zoom = ref(1)
|
||||||
|
const best = ref<number | null>(null)
|
||||||
|
const difficulty = ref<Difficulty>('easy')
|
||||||
|
const levels = Object.keys(LEVELS) as Difficulty[]
|
||||||
|
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
|
||||||
|
const result = ref<'playing' | 'lost' | 'won'>('playing')
|
||||||
|
const confirmation = ref<'leave' | 'replace' | null>(null)
|
||||||
|
const saved = shallowRef<SavedGame | null>(null)
|
||||||
|
const storageError = ref(false)
|
||||||
|
const recordError = ref(false)
|
||||||
|
const needsColor = ref(false)
|
||||||
|
let nextDifficulty: Difficulty = 'easy'
|
||||||
|
let worker: Worker | undefined
|
||||||
|
let active = false
|
||||||
|
let closing = false
|
||||||
|
let started = false
|
||||||
|
let heldView = { left: 0, top: 0 }
|
||||||
|
let lastTick = performance.now()
|
||||||
|
const count = computed(() => revealed.value.filter(Boolean).length)
|
||||||
|
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||||
|
const timeText = (time: number) => {
|
||||||
|
const seconds = Math.floor(time / 1000)
|
||||||
|
return `${Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
const status = computed(() => {
|
||||||
|
if (result.value === 'won') return formatMessage(messages.won)
|
||||||
|
if (result.value === 'lost' && puzzle.value)
|
||||||
|
return formatMessage(messages.lost, {
|
||||||
|
row: Math.floor(mistake.value / puzzle.value.size) + 1,
|
||||||
|
column: (mistake.value % puzzle.value.size) + 1,
|
||||||
|
selected: letter(selected.value),
|
||||||
|
correct: letter(puzzle.value.answer[mistake.value]),
|
||||||
|
})
|
||||||
|
return selected.value < 0 || needsColor.value
|
||||||
|
? formatMessage(messages.choose)
|
||||||
|
: formatMessage(messages.selected, { color: letter(selected.value) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = performance.now()
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
started &&
|
||||||
|
screen.value === 'board' &&
|
||||||
|
result.value === 'playing' &&
|
||||||
|
!confirmation.value &&
|
||||||
|
!document.hidden
|
||||||
|
) {
|
||||||
|
elapsed.value += now - lastTick
|
||||||
|
}
|
||||||
|
lastTick = now
|
||||||
|
}
|
||||||
|
let timer: number | undefined
|
||||||
|
function visibilityChanged() {
|
||||||
|
lastTick = performance.now()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityChanged)
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
if (active || closing) return
|
||||||
|
active = true
|
||||||
|
lastTick = performance.now()
|
||||||
|
timer = window.setInterval(tick, 250)
|
||||||
|
modal.value?.show()
|
||||||
|
loadSaved()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSaved() {
|
||||||
|
storageError.value = false
|
||||||
|
saved.value = null
|
||||||
|
try {
|
||||||
|
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
|
||||||
|
if (saved.value) {
|
||||||
|
difficulty.value = saved.value.difficulty
|
||||||
|
screen.value = 'saved'
|
||||||
|
} else start(difficulty.value)
|
||||||
|
} catch {
|
||||||
|
screen.value = 'saved'
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(level: Difficulty, resume?: SavedGame) {
|
||||||
|
worker?.terminate()
|
||||||
|
confirmation.value = null
|
||||||
|
storageError.value = false
|
||||||
|
recordError.value = false
|
||||||
|
screen.value = 'loading'
|
||||||
|
difficulty.value = level
|
||||||
|
started = false
|
||||||
|
try {
|
||||||
|
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
})
|
||||||
|
worker = pending
|
||||||
|
pending.onerror = () => {
|
||||||
|
if (worker !== pending) return
|
||||||
|
pending.terminate()
|
||||||
|
worker = undefined
|
||||||
|
screen.value = 'error'
|
||||||
|
}
|
||||||
|
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
|
||||||
|
if (worker !== pending || !active) return
|
||||||
|
pending.terminate()
|
||||||
|
worker = undefined
|
||||||
|
if (!event.data.puzzle) {
|
||||||
|
screen.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generated = event.data.puzzle
|
||||||
|
if (resume) {
|
||||||
|
try {
|
||||||
|
validateResume(resume, generated)
|
||||||
|
// Consume the save before playing so losing cannot reload an older, safe state.
|
||||||
|
localStorage.removeItem(SAVE_KEY)
|
||||||
|
} catch {
|
||||||
|
screen.value = 'saved'
|
||||||
|
storageError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved.value = null
|
||||||
|
puzzle.value = generated
|
||||||
|
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
|
||||||
|
selected.value = resume?.selected ?? -1
|
||||||
|
elapsed.value = resume?.elapsed ?? 0
|
||||||
|
started = elapsed.value > 0
|
||||||
|
lastTick = performance.now()
|
||||||
|
zoom.value = resume?.zoom ?? 1
|
||||||
|
mistake.value = -1
|
||||||
|
needsColor.value = false
|
||||||
|
result.value = 'playing'
|
||||||
|
screen.value = 'board'
|
||||||
|
try {
|
||||||
|
best.value = readBest(localStorage, level)
|
||||||
|
} catch {
|
||||||
|
best.value = null
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (resume) await board.value?.setView(resume.left, resume.top)
|
||||||
|
else await board.value?.centerFirstClue()
|
||||||
|
}
|
||||||
|
pending.postMessage({
|
||||||
|
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
|
||||||
|
difficulty: level,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
screen.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(index: number) {
|
||||||
|
const current = puzzle.value
|
||||||
|
if (!current || confirmation.value || result.value !== 'playing') return
|
||||||
|
if (revealed.value[index]) {
|
||||||
|
selected.value = current.answer[index]
|
||||||
|
needsColor.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selected.value < 0) {
|
||||||
|
needsColor.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
started = true
|
||||||
|
if (selected.value !== current.answer[index]) {
|
||||||
|
mistake.value = index
|
||||||
|
result.value = 'lost'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealed.value[index] = true
|
||||||
|
if (revealed.value.every(Boolean)) {
|
||||||
|
result.value = 'won'
|
||||||
|
try {
|
||||||
|
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
|
||||||
|
} catch {
|
||||||
|
recordError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(kind: 'leave' | 'replace') {
|
||||||
|
tick()
|
||||||
|
heldView = board.value?.getView() ?? { left: 0, top: 0 }
|
||||||
|
confirmation.value = kind
|
||||||
|
await nextTick()
|
||||||
|
prompt.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestNew(level: Difficulty = difficulty.value) {
|
||||||
|
nextDifficulty = level
|
||||||
|
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
|
||||||
|
else start(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (confirmation.value) {
|
||||||
|
confirmation.value = null
|
||||||
|
lastTick = performance.now()
|
||||||
|
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
|
||||||
|
else void close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
if (closing) return
|
||||||
|
closing = true
|
||||||
|
active = false
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
await modal.value?.hide()
|
||||||
|
confirmation.value = null
|
||||||
|
puzzle.value = undefined
|
||||||
|
closing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndLeave() {
|
||||||
|
if (!puzzle.value) return
|
||||||
|
try {
|
||||||
|
saveGame(localStorage, {
|
||||||
|
version: 1,
|
||||||
|
seed: puzzle.value.seed,
|
||||||
|
difficulty: puzzle.value.difficulty,
|
||||||
|
revealed: revealed.value.slice(),
|
||||||
|
selected: selected.value,
|
||||||
|
elapsed: elapsed.value,
|
||||||
|
zoom: zoom.value,
|
||||||
|
left: heldView.left,
|
||||||
|
top: heldView.top,
|
||||||
|
})
|
||||||
|
await close()
|
||||||
|
} catch {
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardSaved() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SAVE_KEY)
|
||||||
|
saved.value = null
|
||||||
|
start(difficulty.value)
|
||||||
|
} catch {
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
requestClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
worker?.terminate()
|
||||||
|
window.clearInterval(timer)
|
||||||
|
document.removeEventListener('visibilitychange', visibilityChanged)
|
||||||
|
})
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="formatMessage(messages.title)"
|
||||||
|
width="56rem"
|
||||||
|
max-width="56rem"
|
||||||
|
:closable="false"
|
||||||
|
:close-on-esc="false"
|
||||||
|
actions-divider
|
||||||
|
@keydown.stop="keydown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="color-mine"
|
||||||
|
:style="{
|
||||||
|
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
|
||||||
|
<h3>
|
||||||
|
{{
|
||||||
|
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
|
||||||
|
<div class="mine-toolbar">
|
||||||
|
<div class="mine-levels">
|
||||||
|
<Button
|
||||||
|
v-for="level in levels"
|
||||||
|
:key="level"
|
||||||
|
size="sm"
|
||||||
|
:type="difficulty === level ? 'outlined' : 'base'"
|
||||||
|
:aria-pressed="difficulty === level"
|
||||||
|
@click="level !== difficulty && requestNew(level)"
|
||||||
|
>{{ formatMessage(messages[level]) }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="mine-time">{{ timeText(elapsed) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mine-frame">
|
||||||
|
<p class="mine-status" role="status">{{ status }}</p>
|
||||||
|
<ColorMineBoard
|
||||||
|
ref="board"
|
||||||
|
v-model:zoom="zoom"
|
||||||
|
:puzzle="puzzle"
|
||||||
|
:revealed="revealed"
|
||||||
|
:selected="selected"
|
||||||
|
:playing="result === 'playing'"
|
||||||
|
:lost="result === 'lost'"
|
||||||
|
:mistake="mistake"
|
||||||
|
@paint="paint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mine-stats">
|
||||||
|
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
|
||||||
|
<span v-if="best !== null">{{
|
||||||
|
formatMessage(messages.best, { time: timeText(best) })
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
|
||||||
|
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
|
||||||
|
<details class="mine-rules">
|
||||||
|
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
|
||||||
|
<p>{{ formatMessage(messages.rules) }}</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||||
|
<div v-else-if="screen === 'saved'" class="mine-prompt">
|
||||||
|
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
|
||||||
|
<p v-if="saved">
|
||||||
|
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
|
||||||
|
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="mine-actions">
|
||||||
|
<template v-if="confirmation">
|
||||||
|
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
|
||||||
|
<Button v-if="confirmation === 'leave'" @click="close">{{
|
||||||
|
formatMessage(messages.discard)
|
||||||
|
}}</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
|
||||||
|
<template v-if="screen === 'saved'">
|
||||||
|
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
|
||||||
|
>
|
||||||
|
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
v-else-if="screen !== 'loading'"
|
||||||
|
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
|
||||||
|
color="brand"
|
||||||
|
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
|
||||||
|
>
|
||||||
|
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-mine {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mine-play {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mine-toolbar,
|
||||||
|
.mine-levels,
|
||||||
|
.mine-stats,
|
||||||
|
.mine-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
}
|
||||||
|
.mine-time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
}
|
||||||
|
.mine-meta,
|
||||||
|
.mine-stats,
|
||||||
|
.mine-rules {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.mine-stats {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mine-frame {
|
||||||
|
border: 2px solid var(--mine-ink);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--gap-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.mine-status {
|
||||||
|
margin: 0 0 var(--gap-md);
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.mine-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.mine-prompt h3,
|
||||||
|
.mine-prompt p {
|
||||||
|
margin: 0 0 var(--gap-md);
|
||||||
|
}
|
||||||
|
.mine-rules summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mine-rules p {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
|
||||||
|
|
||||||
|
function connected(board: Board) {
|
||||||
|
const { cross } = createEngine(board.size, board.colors)
|
||||||
|
for (let color = 0; color < board.colors; color++) {
|
||||||
|
const source = board.answer.indexOf(color)
|
||||||
|
if (source < 0) return false
|
||||||
|
const visited = new Set([source])
|
||||||
|
const pending = [source]
|
||||||
|
while (pending.length) {
|
||||||
|
for (const neighbor of cross[pending.pop()!]) {
|
||||||
|
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
|
||||||
|
visited.add(neighbor)
|
||||||
|
pending.push(neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visited.size !== board.answer.filter((c) => c === color).length) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
|
||||||
|
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
|
||||||
|
for (let seed = 1; seed <= 20; seed++) {
|
||||||
|
const puzzle = generate(seed, difficulty)
|
||||||
|
const engine = createEngine(puzzle.size, puzzle.colors)
|
||||||
|
assert.ok(connected(puzzle))
|
||||||
|
assert.ok(puzzle.clues.some((v) => !v))
|
||||||
|
assert.ok(
|
||||||
|
puzzle.clues.filter(Boolean).length >=
|
||||||
|
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
|
||||||
|
)
|
||||||
|
for (let color = 0; color < puzzle.colors; color++) {
|
||||||
|
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
puzzle.numbers,
|
||||||
|
puzzle.answer.map(
|
||||||
|
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
|
||||||
|
if (difficulty === 'hard') {
|
||||||
|
for (let i = 0; i < puzzle.clues.length; i++) {
|
||||||
|
if (
|
||||||
|
!puzzle.clues[i] ||
|
||||||
|
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const fewer = puzzle.clues.slice()
|
||||||
|
fewer[i] = false
|
||||||
|
assert.ok(
|
||||||
|
engine.solve(puzzle, fewer).some((v) => !v),
|
||||||
|
`Redundant clue at ${i}, seed ${seed}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('seeded generation is deterministic for saved games', () => {
|
||||||
|
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
|
||||||
|
const engine = createEngine(3, 2)
|
||||||
|
const boards: Board[] = []
|
||||||
|
for (let bits = 1; bits < 511; bits++) {
|
||||||
|
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
|
||||||
|
const board = {
|
||||||
|
size: 3,
|
||||||
|
colors: 2,
|
||||||
|
answer,
|
||||||
|
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
|
||||||
|
}
|
||||||
|
if (connected(board)) boards.push(board)
|
||||||
|
}
|
||||||
|
for (const board of boards) {
|
||||||
|
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
|
||||||
|
const masks = engine.infer(board, revealed)
|
||||||
|
const candidates = boards.filter((candidate) =>
|
||||||
|
revealed.every(
|
||||||
|
(open, i) =>
|
||||||
|
!open ||
|
||||||
|
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
|
||||||
|
}
|
||||||
|
const hiddenScrambled = {
|
||||||
|
...board,
|
||||||
|
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
|
||||||
|
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
|
||||||
|
}
|
||||||
|
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('diagonal contact does not connect a color', () => {
|
||||||
|
assert.equal(
|
||||||
|
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -0,0 +1,275 @@
|
|||||||
|
export const LEVELS = {
|
||||||
|
easy: { size: 10, colors: 4, density: 0.35 },
|
||||||
|
normal: { size: 25, colors: 6, density: 0.15 },
|
||||||
|
hard: { size: 60, colors: 10, density: 0 },
|
||||||
|
} as const
|
||||||
|
export type Difficulty = keyof typeof LEVELS
|
||||||
|
export interface Board {
|
||||||
|
size: number
|
||||||
|
colors: number
|
||||||
|
answer: number[]
|
||||||
|
numbers: number[]
|
||||||
|
}
|
||||||
|
export interface Puzzle extends Board {
|
||||||
|
clues: boolean[]
|
||||||
|
seed: number
|
||||||
|
difficulty: Difficulty
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
|
||||||
|
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
|
||||||
|
|
||||||
|
function randomSource(seed: number) {
|
||||||
|
return () => {
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[], random: () => number) {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1))
|
||||||
|
;[items[i], items[j]] = [items[j], items[i]]
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEngine(size: number, colors: number) {
|
||||||
|
const count = size * size
|
||||||
|
const all = (1 << colors) - 1
|
||||||
|
const indices = Array.from({ length: count }, (_, i) => i)
|
||||||
|
const around = indices.map((i) => {
|
||||||
|
const neighbors: number[] = []
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const x = (i % size) + dx
|
||||||
|
const y = Math.floor(i / size) + dy
|
||||||
|
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
|
||||||
|
neighbors.push(y * size + x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
})
|
||||||
|
const distance = (a: number, b: number) =>
|
||||||
|
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
|
||||||
|
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
|
||||||
|
|
||||||
|
function grow(random: () => number): Board {
|
||||||
|
const answer: number[] = Array(count).fill(-1)
|
||||||
|
const areas: number[] = Array(colors).fill(0)
|
||||||
|
const seeds: number[] = []
|
||||||
|
for (let color = 0; color < colors; color++) {
|
||||||
|
const candidates = shuffle(
|
||||||
|
indices.filter((i) => answer[i] < 0),
|
||||||
|
random,
|
||||||
|
).slice(0, 80)
|
||||||
|
if (color) {
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Math.min(...seeds.map((s) => distance(b, s))) -
|
||||||
|
Math.min(...seeds.map((s) => distance(a, s))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const seed = candidates[0]
|
||||||
|
answer[seed] = color
|
||||||
|
areas[color]++
|
||||||
|
seeds.push(seed)
|
||||||
|
}
|
||||||
|
const frontier: number[][] = Array.from({ length: colors }, () => [])
|
||||||
|
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
|
||||||
|
function extend(i: number, color: number) {
|
||||||
|
for (const j of cross[i]) {
|
||||||
|
if (answer[j] < 0 && !queued[color][j]) {
|
||||||
|
frontier[color].push(j)
|
||||||
|
queued[color][j] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seeds.forEach(extend)
|
||||||
|
let left = count - colors
|
||||||
|
while (left) {
|
||||||
|
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
|
||||||
|
available.sort((a, b) => areas[a] - areas[b])
|
||||||
|
if (!available.length) throw new Error('Incomplete region growth')
|
||||||
|
const color = available[Math.floor(random() * Math.min(2, available.length))]
|
||||||
|
const list = frontier[color]
|
||||||
|
const position = Math.floor(random() * list.length)
|
||||||
|
const i = list[position]
|
||||||
|
list[position] = list[list.length - 1]
|
||||||
|
list.pop()
|
||||||
|
if (answer[i] >= 0) continue
|
||||||
|
answer[i] = color
|
||||||
|
areas[color]++
|
||||||
|
left--
|
||||||
|
extend(i, color)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
colors,
|
||||||
|
answer,
|
||||||
|
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
|
||||||
|
function propagate(board: Board, revealed: boolean[], masks: number[]) {
|
||||||
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
changed = false
|
||||||
|
for (const i of indices) {
|
||||||
|
if (!revealed[i]) continue
|
||||||
|
const bit = 1 << board.answer[i]
|
||||||
|
const target = board.numbers[i]
|
||||||
|
let sure = 0
|
||||||
|
const optional: number[] = []
|
||||||
|
for (const j of around[i]) {
|
||||||
|
if (masks[j] === bit) sure++
|
||||||
|
else if (masks[j] & bit) optional.push(j)
|
||||||
|
}
|
||||||
|
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
|
||||||
|
if (sure === target || sure + optional.length === target) {
|
||||||
|
for (const j of optional) {
|
||||||
|
masks[j] = sure === target ? masks[j] & ~bit : bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectivity(masks: number[]) {
|
||||||
|
let changed = false
|
||||||
|
for (let color = 0; color < colors; color++) {
|
||||||
|
const bit = 1 << color
|
||||||
|
const source = masks.indexOf(bit)
|
||||||
|
if (source < 0) continue
|
||||||
|
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
|
||||||
|
const order = new Int32Array(count)
|
||||||
|
const low = new Int32Array(count)
|
||||||
|
const parent = new Int32Array(count).fill(-1)
|
||||||
|
const terminals = new Int32Array(count)
|
||||||
|
const edge = new Uint8Array(count)
|
||||||
|
const stack = [source]
|
||||||
|
let time = 1
|
||||||
|
order[source] = low[source] = terminals[source] = 1
|
||||||
|
while (stack.length) {
|
||||||
|
const u = stack[stack.length - 1]
|
||||||
|
if (edge[u] < cross[u].length) {
|
||||||
|
const v = cross[u][edge[u]++]
|
||||||
|
if (!(masks[v] & bit)) continue
|
||||||
|
if (!order[v]) {
|
||||||
|
parent[v] = u
|
||||||
|
order[v] = low[v] = ++time
|
||||||
|
terminals[v] = masks[v] === bit ? 1 : 0
|
||||||
|
stack.push(v)
|
||||||
|
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
|
||||||
|
} else {
|
||||||
|
stack.pop()
|
||||||
|
const p = parent[u]
|
||||||
|
if (p < 0) continue
|
||||||
|
low[p] = Math.min(low[p], low[u])
|
||||||
|
terminals[p] += terminals[u]
|
||||||
|
// A bridge must be this color if its removal separates two known same-color cells.
|
||||||
|
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
|
||||||
|
masks[p] = bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const i of indices) {
|
||||||
|
if (masks[i] & bit && !order[i]) {
|
||||||
|
masks[i] &= ~bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
function infer(board: Board, revealed: boolean[]) {
|
||||||
|
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||||
|
do {
|
||||||
|
propagate(board, revealed, masks)
|
||||||
|
} while (connectivity(masks))
|
||||||
|
return masks
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve(board: Board, clues: boolean[]) {
|
||||||
|
const revealed = clues.slice()
|
||||||
|
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||||
|
while (true) {
|
||||||
|
propagate(board, revealed, masks)
|
||||||
|
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
|
||||||
|
if (!next.length) {
|
||||||
|
if (!connectivity(masks)) return revealed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const i of next) {
|
||||||
|
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
|
||||||
|
revealed[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { around, cross, grow, infer, solve }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generate(seed: number, difficulty: Difficulty): Puzzle {
|
||||||
|
const level = LEVELS[difficulty]
|
||||||
|
const random = randomSource(seed)
|
||||||
|
const engine = createEngine(level.size, level.colors)
|
||||||
|
const board = engine.grow(random)
|
||||||
|
const indices = board.answer.map((_, i) => i)
|
||||||
|
const clues: boolean[] = indices.map(() => false)
|
||||||
|
for (let color = 0; color < level.colors; color++) {
|
||||||
|
const cells = shuffle(
|
||||||
|
indices.filter((i) => board.answer[i] === color),
|
||||||
|
random,
|
||||||
|
)
|
||||||
|
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
|
||||||
|
clues[cells[0]] = true
|
||||||
|
}
|
||||||
|
let solved = engine.solve(board, clues)
|
||||||
|
while (solved.some((value) => !value)) {
|
||||||
|
const cells = shuffle(
|
||||||
|
indices.filter((i) => !solved[i]),
|
||||||
|
random,
|
||||||
|
)
|
||||||
|
cells.sort(
|
||||||
|
(a, b) =>
|
||||||
|
engine.around[b].filter((j) => !solved[j]).length -
|
||||||
|
engine.around[a].filter((j) => !solved[j]).length,
|
||||||
|
)
|
||||||
|
clues[cells[0]] = true
|
||||||
|
solved = engine.solve(board, clues)
|
||||||
|
}
|
||||||
|
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
|
||||||
|
for (const i of shuffle(
|
||||||
|
indices.filter((i) => clues[i]),
|
||||||
|
random,
|
||||||
|
)) {
|
||||||
|
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
|
||||||
|
clues[i] = false
|
||||||
|
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
|
||||||
|
}
|
||||||
|
const target = Math.ceil(indices.length * level.density)
|
||||||
|
const pools = Array.from({ length: level.colors }, (_, color) =>
|
||||||
|
shuffle(
|
||||||
|
indices.filter((i) => board.answer[i] === color && !clues[i]),
|
||||||
|
random,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
let total = clues.filter(Boolean).length
|
||||||
|
while (total < target) {
|
||||||
|
for (const pool of pools) {
|
||||||
|
if (pool.length && total < target) {
|
||||||
|
clues[pool.pop()!] = true
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...board, clues, seed, difficulty }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { type Difficulty, generate } from './engine'
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
|
||||||
|
try {
|
||||||
|
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
|
||||||
|
} catch {
|
||||||
|
self.postMessage({ error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { defineMessages } from '@modrinth/ui'
|
||||||
|
|
||||||
|
export const messages = defineMessages({
|
||||||
|
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
|
||||||
|
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
|
||||||
|
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
|
||||||
|
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
|
||||||
|
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
|
||||||
|
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
|
||||||
|
choose: {
|
||||||
|
id: 'app.easteregg.color-mine.choose',
|
||||||
|
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
id: 'app.easteregg.color-mine.selected',
|
||||||
|
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
|
||||||
|
},
|
||||||
|
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
|
||||||
|
rules: {
|
||||||
|
id: 'app.easteregg.color-mine.rules',
|
||||||
|
defaultMessage:
|
||||||
|
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
id: 'app.easteregg.color-mine.loading',
|
||||||
|
defaultMessage: 'Generating regions and checking the deduction path…',
|
||||||
|
},
|
||||||
|
generateError: {
|
||||||
|
id: 'app.easteregg.color-mine.generateError',
|
||||||
|
defaultMessage: 'Could not generate this board. Try again.',
|
||||||
|
},
|
||||||
|
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
|
||||||
|
progress: {
|
||||||
|
id: 'app.easteregg.color-mine.progress',
|
||||||
|
defaultMessage: '{count} / {total} revealed',
|
||||||
|
},
|
||||||
|
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
|
||||||
|
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
|
||||||
|
lost: {
|
||||||
|
id: 'app.easteregg.color-mine.lost',
|
||||||
|
defaultMessage:
|
||||||
|
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
id: 'app.easteregg.color-mine.review',
|
||||||
|
defaultMessage: 'Unrevealed answers are now shown faded for review.',
|
||||||
|
},
|
||||||
|
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
|
||||||
|
leaveText: {
|
||||||
|
id: 'app.easteregg.color-mine.leaveText',
|
||||||
|
defaultMessage: 'Save to continue later, or discard this unfinished board.',
|
||||||
|
},
|
||||||
|
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
|
||||||
|
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
|
||||||
|
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
|
||||||
|
replaceTitle: {
|
||||||
|
id: 'app.easteregg.color-mine.replaceTitle',
|
||||||
|
defaultMessage: 'Start a new board?',
|
||||||
|
},
|
||||||
|
replaceText: {
|
||||||
|
id: 'app.easteregg.color-mine.replaceText',
|
||||||
|
defaultMessage: 'The current board will be discarded.',
|
||||||
|
},
|
||||||
|
savedTitle: {
|
||||||
|
id: 'app.easteregg.color-mine.savedTitle',
|
||||||
|
defaultMessage: 'An unfinished board is saved',
|
||||||
|
},
|
||||||
|
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
|
||||||
|
storageError: {
|
||||||
|
id: 'app.easteregg.color-mine.storageError',
|
||||||
|
defaultMessage:
|
||||||
|
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
|
||||||
|
},
|
||||||
|
recordError: {
|
||||||
|
id: 'app.easteregg.color-mine.recordError',
|
||||||
|
defaultMessage: 'Finished, but the best time could not be saved.',
|
||||||
|
},
|
||||||
|
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
|
||||||
|
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
|
||||||
|
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
|
||||||
|
board: {
|
||||||
|
id: 'app.easteregg.color-mine.board',
|
||||||
|
defaultMessage: '{size} by {size} color deduction board',
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
id: 'app.easteregg.color-mine.hidden',
|
||||||
|
defaultMessage: 'Row {row}, column {column}, unrevealed',
|
||||||
|
},
|
||||||
|
revealed: {
|
||||||
|
id: 'app.easteregg.color-mine.revealed',
|
||||||
|
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
|
||||||
|
.color-mine {
|
||||||
|
--mine-color-0: var(--color-red);
|
||||||
|
--mine-color-1: #d7ac37;
|
||||||
|
--mine-color-2: var(--color-green);
|
||||||
|
--mine-color-3: var(--color-blue);
|
||||||
|
--mine-color-4: var(--color-purple);
|
||||||
|
--mine-color-5: var(--color-orange);
|
||||||
|
--mine-color-6: #97ba35;
|
||||||
|
--mine-color-7: #22b6b8;
|
||||||
|
--mine-color-8: var(--color-pink);
|
||||||
|
--mine-color-9: #8999ac;
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { generate } from './engine.ts'
|
||||||
|
import {
|
||||||
|
parseSave,
|
||||||
|
readBest,
|
||||||
|
recordBest,
|
||||||
|
SAVE_KEY,
|
||||||
|
type SavedGame,
|
||||||
|
saveGame,
|
||||||
|
validateResume,
|
||||||
|
} from './storage.ts'
|
||||||
|
|
||||||
|
const puzzle = generate(123, 'easy')
|
||||||
|
const saved: SavedGame = {
|
||||||
|
version: 1,
|
||||||
|
seed: puzzle.seed,
|
||||||
|
difficulty: puzzle.difficulty,
|
||||||
|
revealed: puzzle.clues.slice(),
|
||||||
|
selected: -1,
|
||||||
|
elapsed: 4321,
|
||||||
|
zoom: 1,
|
||||||
|
left: 12,
|
||||||
|
top: 34,
|
||||||
|
}
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
values.set(key, value)
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
values.delete(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('save round trip keeps board progress, brush, timer and viewport', () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
saveGame(storage, saved)
|
||||||
|
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||||
|
validateResume(saved, puzzle)
|
||||||
|
assert.equal(parseSave(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed and incompatible saves are rejected without overwriting data', () => {
|
||||||
|
for (const bad of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ ...saved, version: 2 },
|
||||||
|
{ ...saved, difficulty: '__proto__' },
|
||||||
|
{ ...saved, revealed: [true] },
|
||||||
|
{ ...saved, elapsed: -1 },
|
||||||
|
{ ...saved, selected: 15 },
|
||||||
|
{ ...saved, zoom: 5 },
|
||||||
|
{ ...saved, seed: 1.2 },
|
||||||
|
{ ...saved, top: -1 },
|
||||||
|
{ ...saved, revealed: saved.revealed.map(() => true) },
|
||||||
|
]) {
|
||||||
|
assert.throws(() => parseSave(JSON.stringify(bad)))
|
||||||
|
}
|
||||||
|
assert.throws(() => parseSave('{invalid'))
|
||||||
|
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
|
||||||
|
assert.throws(() =>
|
||||||
|
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
|
||||||
|
)
|
||||||
|
const storage = memoryStorage()
|
||||||
|
saveGame(storage, saved)
|
||||||
|
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
|
||||||
|
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('storage failures are surfaced so the UI can keep the active board', () => {
|
||||||
|
assert.throws(() =>
|
||||||
|
saveGame(
|
||||||
|
{
|
||||||
|
...memoryStorage(),
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('Quota exceeded')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
saved,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('best times are independent per difficulty and only improve', () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
assert.equal(readBest(storage, 'easy'), null)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 1000), 1000)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 2000), 1000)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 500), 500)
|
||||||
|
assert.equal(recordBest(storage, 'hard', 9000), 9000)
|
||||||
|
assert.equal(readBest(storage, 'easy'), 500)
|
||||||
|
assert.throws(() => recordBest(storage, 'easy', -1))
|
||||||
|
})
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
|
||||||
|
|
||||||
|
export const SAVE_KEY = 'starlight.color-mine.save.v1'
|
||||||
|
const BEST_KEY = 'starlight.color-mine.best.v1'
|
||||||
|
export interface SavedGame {
|
||||||
|
version: 1
|
||||||
|
seed: number
|
||||||
|
difficulty: Difficulty
|
||||||
|
revealed: boolean[]
|
||||||
|
selected: number
|
||||||
|
elapsed: number
|
||||||
|
zoom: number
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
}
|
||||||
|
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||||
|
const finite = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
export function parseSave(raw: string | null): SavedGame | null {
|
||||||
|
if (raw === null) return null
|
||||||
|
const value = JSON.parse(raw) as Partial<SavedGame> | null
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
value.version !== 1 ||
|
||||||
|
!value.difficulty ||
|
||||||
|
!Object.keys(LEVELS).includes(value.difficulty)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid saved game')
|
||||||
|
}
|
||||||
|
const level = LEVELS[value.difficulty]
|
||||||
|
if (
|
||||||
|
!finite(value.seed) ||
|
||||||
|
!Number.isInteger(value.seed) ||
|
||||||
|
value.seed < 0 ||
|
||||||
|
value.seed > 0xffffffff ||
|
||||||
|
!Array.isArray(value.revealed) ||
|
||||||
|
value.revealed.length !== level.size ** 2 ||
|
||||||
|
value.revealed.some((v) => typeof v !== 'boolean') ||
|
||||||
|
value.revealed.every(Boolean) ||
|
||||||
|
!finite(value.selected) ||
|
||||||
|
!Number.isInteger(value.selected) ||
|
||||||
|
value.selected < -1 ||
|
||||||
|
value.selected >= level.colors ||
|
||||||
|
!finite(value.elapsed) ||
|
||||||
|
value.elapsed < 0 ||
|
||||||
|
!finite(value.zoom) ||
|
||||||
|
value.zoom < 0.7 ||
|
||||||
|
value.zoom > 1.6 ||
|
||||||
|
!finite(value.left) ||
|
||||||
|
value.left < 0 ||
|
||||||
|
!finite(value.top) ||
|
||||||
|
value.top < 0
|
||||||
|
)
|
||||||
|
throw new Error('Invalid saved game')
|
||||||
|
return value as SavedGame
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
|
||||||
|
if (
|
||||||
|
saved.seed !== puzzle.seed ||
|
||||||
|
saved.difficulty !== puzzle.difficulty ||
|
||||||
|
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
|
||||||
|
(saved.selected >= 0 &&
|
||||||
|
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
|
||||||
|
) {
|
||||||
|
throw new Error('Saved game does not match puzzle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGame(storage: StoragePort, saved: SavedGame) {
|
||||||
|
const raw = JSON.stringify(saved)
|
||||||
|
parseSave(raw)
|
||||||
|
storage.setItem(SAVE_KEY, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
|
||||||
|
const raw = storage.getItem(BEST_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const value: unknown = JSON.parse(raw)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const time = (value as Record<string, unknown>)[difficulty]
|
||||||
|
return finite(time) && time >= 0 ? time : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
|
||||||
|
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
|
||||||
|
const best = Object.fromEntries(
|
||||||
|
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
|
||||||
|
)
|
||||||
|
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
|
||||||
|
storage.setItem(BEST_KEY, JSON.stringify(best))
|
||||||
|
return best[difficulty]
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
|
|||||||
|
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { list } from '@/helpers/instance'
|
import { list } from '@/helpers/instance'
|
||||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||||
|
|
||||||
@ -91,7 +90,6 @@ defineExpose({
|
|||||||
instances.value = instanceValues
|
instances.value = instanceValues
|
||||||
modal.value.show()
|
modal.value.show()
|
||||||
|
|
||||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -102,11 +100,6 @@ async function addServer(instance) {
|
|||||||
instance.added = true
|
instance.added = true
|
||||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||||
|
|
||||||
trackEvent('AddServerToInstance', {
|
|
||||||
server_name: serverName.value,
|
|
||||||
instance_name: instance.name,
|
|
||||||
source: 'AddServerToInstanceModal',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err)
|
handleError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { install_duplicate_instance } from '@/helpers/install'
|
import { install_duplicate_instance } from '@/helpers/install'
|
||||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||||
@ -49,10 +48,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
|
|||||||
|
|
||||||
async function duplicateInstance() {
|
async function duplicateInstance() {
|
||||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||||
trackEvent('InstanceDuplicate', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||||
@ -104,7 +99,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
|||||||
async function resetIcon() {
|
async function resetIcon() {
|
||||||
icon.value = undefined
|
icon.value = undefined
|
||||||
await edit_icon(instance.value.id, null).catch(handleError)
|
await edit_icon(instance.value.id, null).catch(handleError)
|
||||||
trackEvent('InstanceRemoveIcon')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setIcon() {
|
async function setIcon() {
|
||||||
@ -116,7 +110,6 @@ async function setIcon() {
|
|||||||
icon.value = picked.path
|
icon.value = picked.path
|
||||||
try {
|
try {
|
||||||
await edit_icon(instance.value.id, picked.path)
|
await edit_icon(instance.value.id, picked.path)
|
||||||
trackEvent('InstanceSetIcon')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
icon.value = previousIcon
|
icon.value = previousIcon
|
||||||
handleError(error)
|
handleError(error)
|
||||||
@ -253,10 +246,6 @@ async function removeInstance() {
|
|||||||
removing.value = true
|
removing.value = true
|
||||||
const path = instance.value.id
|
const path = instance.value.id
|
||||||
|
|
||||||
trackEvent('InstanceRemove', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
|
|
||||||
await router.push({ path: '/' })
|
await router.push({ path: '/' })
|
||||||
await remove(path).catch(handleError)
|
await remove(path).catch(handleError)
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||||
import {
|
import {
|
||||||
@ -333,10 +332,6 @@ provideInstallationSettings({
|
|||||||
afterSave: async () => {
|
afterSave: async () => {
|
||||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
debug('afterSave: done')
|
debug('afterSave: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -345,40 +340,27 @@ provideInstallationSettings({
|
|||||||
repairing.value = true
|
repairing.value = true
|
||||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||||
repairing.value = false
|
repairing.value = false
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
debug('repair: done')
|
debug('repair: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
async reinstallModpack() {
|
async reinstallModpack() {
|
||||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||||
reinstalling.value = true
|
reinstalling.value = true
|
||||||
let shouldTrack = false
|
|
||||||
try {
|
try {
|
||||||
if (isImportedModpack.value) {
|
if (isImportedModpack.value) {
|
||||||
shouldTrack = await installLocalModpackFromPicker()
|
await installLocalModpackFromPicker()
|
||||||
} else if (isCurseForgeLinkedModpack.value) {
|
} else if (isCurseForgeLinkedModpack.value) {
|
||||||
const fileId = Number(instance.value.link?.version_id)
|
const fileId = Number(instance.value.link?.version_id)
|
||||||
if (!Number.isFinite(fileId)) {
|
if (!Number.isFinite(fileId)) {
|
||||||
throw new Error('Invalid CurseForge file ID')
|
throw new Error('Invalid CurseForge file ID')
|
||||||
}
|
}
|
||||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||||
shouldTrack = true
|
|
||||||
} else {
|
} else {
|
||||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||||
shouldTrack = true
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reinstalling.value = false
|
reinstalling.value = false
|
||||||
}
|
}
|
||||||
if (shouldTrack) {
|
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
debug('reinstallModpack: done')
|
debug('reinstallModpack: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -386,13 +368,7 @@ provideInstallationSettings({
|
|||||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||||
reinstalling.value = true
|
reinstalling.value = true
|
||||||
try {
|
try {
|
||||||
const installed = await installLocalModpackFromPicker()
|
await installLocalModpackFromPicker()
|
||||||
if (installed) {
|
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
reinstalling.value = false
|
reinstalling.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
defineMessages,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
Toggle,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
saved: [privacy: PrivacySettings]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const CONSENT_VERSION = 1
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
|
||||||
const telemetry = ref(true)
|
|
||||||
const discordRpc = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: {
|
|
||||||
id: 'app.privacy-consent.title',
|
|
||||||
defaultMessage: 'Privacy & security',
|
|
||||||
},
|
|
||||||
intro: {
|
|
||||||
id: 'app.privacy-consent.intro',
|
|
||||||
defaultMessage:
|
|
||||||
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
|
|
||||||
},
|
|
||||||
telemetry: {
|
|
||||||
id: 'app.privacy-consent.telemetry',
|
|
||||||
defaultMessage: 'Allow anonymous telemetry',
|
|
||||||
},
|
|
||||||
telemetryDescription: {
|
|
||||||
id: 'app.privacy-consent.telemetry-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
|
|
||||||
},
|
|
||||||
discordRpc: {
|
|
||||||
id: 'app.privacy-consent.discord-rpc',
|
|
||||||
defaultMessage: 'Discord Rich Presence',
|
|
||||||
},
|
|
||||||
discordRpcDescription: {
|
|
||||||
id: 'app.privacy-consent.discord-rpc-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Shows your current launcher or game activity in Discord when Discord is running.',
|
|
||||||
},
|
|
||||||
privacyPolicy: {
|
|
||||||
id: 'app.privacy-consent.privacy-policy',
|
|
||||||
defaultMessage: 'Read the privacy policy',
|
|
||||||
},
|
|
||||||
continue: {
|
|
||||||
id: 'app.privacy-consent.continue',
|
|
||||||
defaultMessage: 'Save and continue',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function show(current: PrivacySettings) {
|
|
||||||
telemetry.value = true
|
|
||||||
discordRpc.value = current.discord_rpc
|
|
||||||
modal.value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (saving.value) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const privacy = await savePrivacySettings({
|
|
||||||
telemetry: telemetry.value,
|
|
||||||
discord_rpc: discordRpc.value,
|
|
||||||
consent_version: CONSENT_VERSION,
|
|
||||||
})
|
|
||||||
modal.value?.hide()
|
|
||||||
emit('saved', privacy)
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ show })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
|
|
||||||
<p class="m-0 leading-relaxed text-primary">
|
|
||||||
{{ formatMessage(messages.intro) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<label for="consent-telemetry" class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.telemetry) }}
|
|
||||||
</label>
|
|
||||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
|
||||||
{{ formatMessage(messages.telemetryDescription) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<label for="consent-discord-rpc" class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.discordRpc) }}
|
|
||||||
</label>
|
|
||||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
|
||||||
{{ formatMessage(messages.discordRpcDescription) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
|
|
||||||
<ExternalIcon />
|
|
||||||
{{ formatMessage(messages.privacyPolicy) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button type="button" :disabled="saving" @click="save">
|
|
||||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
|
||||||
{{ formatMessage(messages.continue) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
|
|||||||
color: var(--color-contrast);
|
color: var(--color-contrast);
|
||||||
font-size: 4.5rem;
|
font-size: 4.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
// Keep descenders inside the line box clipped by the wordmark reveal animation.
|
||||||
|
line-height: normal;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
|||||||
@ -204,15 +204,6 @@ export const onboardingMessages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||||
},
|
},
|
||||||
privacyTitle: {
|
|
||||||
id: 'app.onboarding.privacy.title',
|
|
||||||
defaultMessage: 'Your data, your call',
|
|
||||||
},
|
|
||||||
privacyDescription: {
|
|
||||||
id: 'app.onboarding.privacy.description',
|
|
||||||
defaultMessage:
|
|
||||||
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
|
|
||||||
},
|
|
||||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||||
updatesDescription: {
|
updatesDescription: {
|
||||||
id: 'app.onboarding.updates.description',
|
id: 'app.onboarding.updates.description',
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
|
||||||
|
|
||||||
ChevronDownIcon,
|
|
||||||
ExternalIcon,
|
|
||||||
ScaleIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
|
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
|
||||||
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
|
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
|
||||||
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
|
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
|
||||||
import { AxolotlBrandConfig } from '@/config'
|
import { AxolotlBrandConfig } from '@/config'
|
||||||
|
import { contributors, type TeamMember, teamMembers } from '@/data/about'
|
||||||
import { contributors, teamMembers, type TeamMember } from '@/data/about'
|
|
||||||
|
|
||||||
import AboutScene from '../AboutScene.vue'
|
import AboutScene from '../AboutScene.vue'
|
||||||
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
|
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
|
||||||
@ -24,7 +19,7 @@ const version = await getVersion()
|
|||||||
const experienceHost = ref<HTMLElement>()
|
const experienceHost = ref<HTMLElement>()
|
||||||
const activeMemberExperience = shallowRef<AboutMemberExperience>()
|
const activeMemberExperience = shallowRef<AboutMemberExperience>()
|
||||||
const pressingMemberName = ref<string>()
|
const pressingMemberName = ref<string>()
|
||||||
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
|
let longPressTimer: number | undefined
|
||||||
let pressStart = { x: 0, y: 0 }
|
let pressStart = { x: 0, y: 0 }
|
||||||
let suppressNextMemberClick = false
|
let suppressNextMemberClick = false
|
||||||
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
|
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
|
||||||
@ -78,6 +73,24 @@ function closeMemberExperience() {
|
|||||||
|
|
||||||
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
|
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
|
||||||
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
|
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
|
||||||
|
const ColorMineModal = defineAsyncComponent(
|
||||||
|
() => import('@/components/ui/easteregg/color-mine/ColorMineModal.vue'),
|
||||||
|
)
|
||||||
|
const colorMineModal = ref<{ show: () => void }>()
|
||||||
|
const colorMineMounted = ref(false)
|
||||||
|
const colorMinePending = ref(false)
|
||||||
|
|
||||||
|
function openColorMine() {
|
||||||
|
colorMineMounted.value = true
|
||||||
|
if (colorMineModal.value) colorMineModal.value.show()
|
||||||
|
else colorMinePending.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorMineReady() {
|
||||||
|
if (!colorMinePending.value) return
|
||||||
|
colorMinePending.value = false
|
||||||
|
colorMineModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
let typedBuffer = ''
|
let typedBuffer = ''
|
||||||
const secretCodes = ['starlight']
|
const secretCodes = ['starlight']
|
||||||
@ -166,7 +179,8 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
attribution: {
|
attribution: {
|
||||||
id: 'app.settings.about.attribution',
|
id: 'app.settings.about.attribution',
|
||||||
defaultMessage: 'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
|
defaultMessage:
|
||||||
|
'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
|
||||||
},
|
},
|
||||||
notAffiliated: {
|
notAffiliated: {
|
||||||
id: 'app.settings.about.not-affiliated',
|
id: 'app.settings.about.not-affiliated',
|
||||||
@ -194,7 +208,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
|
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -237,7 +250,6 @@ const messages = defineMessages({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
|
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
|
||||||
<ScaleIcon class="size-5 text-secondary" />
|
<ScaleIcon class="size-5 text-secondary" />
|
||||||
@ -288,9 +300,31 @@ const messages = defineMessages({
|
|||||||
{{ formatMessage(messages.developmentTeam) }}
|
{{ formatMessage(messages.developmentTeam) }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<template v-for="member in teamMembers" :key="member.name">
|
||||||
|
<div
|
||||||
|
v-if="member.name === 'Disy920'"
|
||||||
|
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
|
||||||
|
>
|
||||||
|
<ColorMineAvatar
|
||||||
|
:src="member.avatarUrl"
|
||||||
|
:name="member.name"
|
||||||
|
:href="member.url"
|
||||||
|
@activate="openColorMine"
|
||||||
|
/>
|
||||||
<a
|
<a
|
||||||
v-for="member in teamMembers"
|
:href="member.url"
|
||||||
:key="member.name"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-3"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
|
||||||
|
member.name
|
||||||
|
}}</span>
|
||||||
|
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
:href="member.url ?? undefined"
|
:href="member.url ?? undefined"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@ -315,6 +349,7 @@ const messages = defineMessages({
|
|||||||
</span>
|
</span>
|
||||||
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||||
</a>
|
</a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<details class="group pt-4 about-settings-details">
|
<details class="group pt-4 about-settings-details">
|
||||||
@ -360,6 +395,7 @@ const messages = defineMessages({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EasterEggGameModal ref="gameModal" />
|
<EasterEggGameModal ref="gameModal" />
|
||||||
|
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
|
||||||
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
|
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
|
|||||||
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
|
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
|
||||||
import OracleLogo from '@/assets/java-vendors/oracle.png'
|
import OracleLogo from '@/assets/java-vendors/oracle.png'
|
||||||
import SapLogo from '@/assets/java-vendors/sap.png'
|
import SapLogo from '@/assets/java-vendors/sap.png'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
|
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
@ -117,7 +116,6 @@ function backToVendors() {
|
|||||||
|
|
||||||
async function downloadVersion(info) {
|
async function downloadVersion(info) {
|
||||||
downloading.value = info.major_version
|
downloading.value = info.major_version
|
||||||
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
|
|
||||||
modal.value?.hide()
|
modal.value?.hide()
|
||||||
|
|
||||||
const job = await download_java(info.vendor, info.major_version).catch(handleError)
|
const job = await download_java(info.vendor, info.major_version).catch(handleError)
|
||||||
|
|||||||
@ -23,7 +23,6 @@ const { formatMessage } = useVIntl()
|
|||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const isDevEnvironment = await isDev()
|
const isDevEnvironment = await isDev()
|
||||||
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
|
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
|
||||||
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
resetToDefault: {
|
resetToDefault: {
|
||||||
id: 'app.settings.feature-flags.reset-to-default',
|
id: 'app.settings.feature-flags.reset-to-default',
|
||||||
@ -53,10 +52,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.settings.about.preview-minecraft-crash-modal',
|
id: 'app.settings.about.preview-minecraft-crash-modal',
|
||||||
defaultMessage: 'Preview Minecraft crash window',
|
defaultMessage: 'Preview Minecraft crash window',
|
||||||
},
|
},
|
||||||
previewPrivacyConsentModal: {
|
|
||||||
id: 'app.settings.about.preview-privacy-consent-modal',
|
|
||||||
defaultMessage: 'Preview privacy & security modal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const settings = ref(await getSettings())
|
const settings = ref(await getSettings())
|
||||||
@ -127,9 +122,6 @@ watch(
|
|||||||
<Button type="base" @click="previewMinecraftCrashModal?.()">
|
<Button type="base" @click="previewMinecraftCrashModal?.()">
|
||||||
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
|
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="base" @click="previewPrivacyConsentModal?.()">
|
|
||||||
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue
|
|||||||
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
|
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
|
||||||
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
|
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
|
||||||
import useMemorySlider from '@/composables/useMemorySlider'
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { collectGcContext } from '@/helpers/gc/context'
|
import { collectGcContext } from '@/helpers/gc/context'
|
||||||
import { wait_for_install_job } from '@/helpers/install'
|
import { wait_for_install_job } from '@/helpers/install'
|
||||||
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
|
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
|
||||||
@ -249,7 +248,6 @@ async function runScan(exhaustive) {
|
|||||||
|
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanMode.value = 'quick'
|
scanMode.value = 'quick'
|
||||||
trackEvent('JavaQuickScan', { source: 'settings' })
|
|
||||||
try {
|
try {
|
||||||
await find_filtered_jres(null, false, true, false).catch(handleError)
|
await find_filtered_jres(null, false, true, false).catch(handleError)
|
||||||
} finally {
|
} finally {
|
||||||
@ -262,7 +260,6 @@ async function confirmDeepScan() {
|
|||||||
showDeepScanConfirm.value = false
|
showDeepScanConfirm.value = false
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanMode.value = 'deep'
|
scanMode.value = 'deep'
|
||||||
trackEvent('JavaDeepScan', { source: 'settings' })
|
|
||||||
try {
|
try {
|
||||||
await find_filtered_jres(null, true, true, true).catch(handleError)
|
await find_filtered_jres(null, true, true, true).catch(handleError)
|
||||||
} finally {
|
} finally {
|
||||||
@ -280,7 +277,6 @@ async function handleManualAdd() {
|
|||||||
if (!javaInfo) return
|
if (!javaInfo) return
|
||||||
|
|
||||||
await set_java_version(javaInfo).catch(handleError)
|
await set_java_version(javaInfo).catch(handleError)
|
||||||
trackEvent('JavaManualSelect', { path: filePath })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onJavaDownloaded(job) {
|
async function onJavaDownloaded(job) {
|
||||||
|
|||||||
@ -1,156 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
|
|
||||||
|
|
||||||
import SettingsRow from './SettingsRow.vue'
|
|
||||||
import SettingsSaveStatus from './SettingsSaveStatus.vue'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
const privacy = ref(await getPrivacySettings())
|
|
||||||
const telemetrySaving = ref(false)
|
|
||||||
const discordSaving = ref(false)
|
|
||||||
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
|
|
||||||
const retrySave = ref<(() => void) | undefined>()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
telemetry: {
|
|
||||||
id: 'app.settings.privacy.telemetry',
|
|
||||||
defaultMessage: 'Allow telemetry',
|
|
||||||
},
|
|
||||||
telemetryDescription: {
|
|
||||||
id: 'app.settings.privacy.telemetry-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
|
|
||||||
},
|
|
||||||
discordRpc: {
|
|
||||||
id: 'app.settings.privacy.discord-rpc',
|
|
||||||
defaultMessage: 'Discord Rich Presence',
|
|
||||||
},
|
|
||||||
discordRpcDescription: {
|
|
||||||
id: 'app.settings.privacy.discord-rpc-description',
|
|
||||||
defaultMessage: 'Show your current launcher or game activity in Discord.',
|
|
||||||
},
|
|
||||||
dataHandling: {
|
|
||||||
id: 'app.settings.privacy.data-handling',
|
|
||||||
defaultMessage:
|
|
||||||
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const saveStatus = computed(() => {
|
|
||||||
if (telemetrySaving.value || discordSaving.value) return 'saving'
|
|
||||||
return lastSaveState.value
|
|
||||||
})
|
|
||||||
|
|
||||||
async function updateTelemetry(value: boolean) {
|
|
||||||
if (telemetrySaving.value) return
|
|
||||||
const previous = privacy.value.telemetry
|
|
||||||
privacy.value.telemetry = value
|
|
||||||
telemetrySaving.value = true
|
|
||||||
lastSaveState.value = 'idle'
|
|
||||||
retrySave.value = undefined
|
|
||||||
try {
|
|
||||||
const saved = await setTelemetryEnabled(value)
|
|
||||||
privacy.value.telemetry = saved.telemetry
|
|
||||||
privacy.value.consent_version = saved.consent_version
|
|
||||||
lastSaveState.value = 'saved'
|
|
||||||
} catch (error) {
|
|
||||||
privacy.value.telemetry = previous
|
|
||||||
retrySave.value = () => void updateTelemetry(value)
|
|
||||||
lastSaveState.value = 'error'
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
telemetrySaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateDiscordRpc(value: boolean) {
|
|
||||||
if (discordSaving.value) return
|
|
||||||
const previous = privacy.value.discord_rpc
|
|
||||||
privacy.value.discord_rpc = value
|
|
||||||
discordSaving.value = true
|
|
||||||
lastSaveState.value = 'idle'
|
|
||||||
retrySave.value = undefined
|
|
||||||
try {
|
|
||||||
const saved = await setDiscordRpcEnabled(value)
|
|
||||||
privacy.value.discord_rpc = saved.discord_rpc
|
|
||||||
lastSaveState.value = 'saved'
|
|
||||||
} catch (error) {
|
|
||||||
privacy.value.discord_rpc = previous
|
|
||||||
retrySave.value = () => void updateDiscordRpc(value)
|
|
||||||
lastSaveState.value = 'error'
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
discordSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex w-full flex-col gap-4">
|
|
||||||
<header class="settings-page-header">
|
|
||||||
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
|
|
||||||
</header>
|
|
||||||
<div class="settings-page-card">
|
|
||||||
<SettingsRow>
|
|
||||||
<template #label>
|
|
||||||
<span id="settings-target-privacy-telemetry" tabindex="-1">
|
|
||||||
{{ formatMessage(messages.telemetry) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
|
|
||||||
<template #control>
|
|
||||||
<Toggle
|
|
||||||
id="privacy-telemetry"
|
|
||||||
:model-value="privacy.telemetry"
|
|
||||||
:disabled="telemetrySaving"
|
|
||||||
@update:model-value="(value) => updateTelemetry(!!value)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<template #label>
|
|
||||||
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
|
|
||||||
{{ formatMessage(messages.discordRpc) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
|
|
||||||
<template #control>
|
|
||||||
<Toggle
|
|
||||||
id="privacy-discord-rpc"
|
|
||||||
:model-value="privacy.discord_rpc"
|
|
||||||
:disabled="discordSaving"
|
|
||||||
@update:model-value="(value) => updateDiscordRpc(!!value)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.settings-page-card {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid
|
|
||||||
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page-header {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -10,7 +10,6 @@ export type SettingsCategoryId =
|
|||||||
| 'content-downloads'
|
| 'content-downloads'
|
||||||
| 'network-multiplayer'
|
| 'network-multiplayer'
|
||||||
| 'storage-backups'
|
| 'storage-backups'
|
||||||
| 'privacy-data'
|
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'feature-flags'
|
| 'feature-flags'
|
||||||
@ -106,15 +105,6 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
|||||||
group: 'data-privacy',
|
group: 'data-privacy',
|
||||||
onboardingId: 'settings-tab-storage-backups',
|
onboardingId: 'settings-tab-storage-backups',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'privacy-data',
|
|
||||||
name: defineMessage({
|
|
||||||
id: 'app.settings.tabs.privacy-data',
|
|
||||||
defaultMessage: 'Privacy & data sharing',
|
|
||||||
}),
|
|
||||||
group: 'data-privacy',
|
|
||||||
onboardingId: 'settings-tab-privacy-data',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'updates',
|
id: 'updates',
|
||||||
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
||||||
|
|||||||
@ -72,10 +72,6 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
|||||||
icon: ArchiveIcon,
|
icon: ArchiveIcon,
|
||||||
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
|
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
|
||||||
},
|
},
|
||||||
'privacy-data': {
|
|
||||||
icon: ShieldIcon,
|
|
||||||
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
|
|
||||||
},
|
|
||||||
updates: {
|
updates: {
|
||||||
icon: RefreshCwIcon,
|
icon: RefreshCwIcon,
|
||||||
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
||||||
|
|||||||
@ -221,18 +221,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
|||||||
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
|
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
|
||||||
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
|
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'privacy-telemetry',
|
|
||||||
categoryId: 'privacy-data',
|
|
||||||
targetId: 'settings-target-privacy-telemetry',
|
|
||||||
label: message('app.settings.privacy.telemetry', 'Telemetry'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'privacy-discord-rpc',
|
|
||||||
categoryId: 'privacy-data',
|
|
||||||
targetId: 'settings-target-privacy-discord-rpc',
|
|
||||||
label: message('app.settings.privacy.discord-rpc', 'Discord rich presence'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'java-installations',
|
id: 'java-installations',
|
||||||
categoryId: 'java-performance',
|
categoryId: 'java-performance',
|
||||||
|
|||||||
@ -28,7 +28,6 @@ const settingsComponentFiles = {
|
|||||||
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
|
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
|
||||||
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
|
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
|
||||||
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
||||||
'privacy-data': ['./PrivacySettings.vue'],
|
|
||||||
updates: ['./UpdateSettings.vue'],
|
updates: ['./UpdateSettings.vue'],
|
||||||
about: ['./AboutSettings.vue'],
|
about: ['./AboutSettings.vue'],
|
||||||
'feature-flags': ['./FeatureFlagSettings.vue'],
|
'feature-flags': ['./FeatureFlagSettings.vue'],
|
||||||
@ -169,7 +168,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
|||||||
'content-downloads',
|
'content-downloads',
|
||||||
'network-multiplayer',
|
'network-multiplayer',
|
||||||
])
|
])
|
||||||
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups', 'privacy-data'])
|
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
|
||||||
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
|
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
|
||||||
assert.deepEqual(categoriesForGroup('developer'), [])
|
assert.deepEqual(categoriesForGroup('developer'), [])
|
||||||
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project } from '@/helpers/cache'
|
import { get_project } from '@/helpers/cache'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -108,11 +107,6 @@ const play = async (event: MouseEvent) => {
|
|||||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: 'InstanceItem',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
emit('play')
|
emit('play')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -122,11 +116,6 @@ const stop = async (event: MouseEvent) => {
|
|||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await kill(props.instance.id).catch(handleError)
|
await kill(props.instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: 'InstanceItem',
|
|
||||||
})
|
|
||||||
emit('stop')
|
emit('stop')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
receiveSkinSiteSession,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteFrameUrl,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from './skin-site-session.ts'
|
||||||
|
|
||||||
|
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
|
||||||
|
const frame = {} as Window
|
||||||
|
const message = (status: string, user?: unknown) =>
|
||||||
|
({
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: { type: 'starlight-skin-session', status, user },
|
||||||
|
}) as MessageEvent
|
||||||
|
resetSkinSiteSession()
|
||||||
|
openSkinSiteLogin()
|
||||||
|
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
|
||||||
|
const signedIn = message('signed-in', {
|
||||||
|
uuid: 'test-user',
|
||||||
|
username: '测试用户',
|
||||||
|
jwt: 'must-not-copy',
|
||||||
|
})
|
||||||
|
assert.equal(
|
||||||
|
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
|
||||||
|
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
|
||||||
|
assert.equal(skinSiteUser.value, null)
|
||||||
|
assert.equal(receiveSkinSiteSession(signedIn, frame), true)
|
||||||
|
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
|
||||||
|
assert.equal(skinSiteStatus.value, 'signed-in')
|
||||||
|
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
|
||||||
|
assert.equal(receiveSkinSiteSession(message('checking'), frame), true)
|
||||||
|
assert.equal(skinSiteUser.value, null)
|
||||||
|
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
|
||||||
|
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
|
||||||
|
receiveSkinSiteSession(message('signed-out'), frame)
|
||||||
|
assert.equal(skinSiteUser.value, null)
|
||||||
|
assert.equal(skinSiteStatus.value, 'signed-out')
|
||||||
|
receiveSkinSiteSession(message('error'), frame)
|
||||||
|
assert.equal(skinSiteStatus.value, 'error')
|
||||||
|
resetSkinSiteSession()
|
||||||
|
})
|
||||||
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { readonly, ref } from 'vue'
|
||||||
|
|
||||||
|
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||||
|
export type SkinSiteUser = { uuid: string; username: string }
|
||||||
|
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
||||||
|
|
||||||
|
const user = ref<SkinSiteUser | null>(null)
|
||||||
|
const status = ref<SkinSiteStatus>('signed-out')
|
||||||
|
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||||
|
|
||||||
|
export const skinSiteUser = readonly(user)
|
||||||
|
export const skinSiteStatus = readonly(status)
|
||||||
|
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||||
|
|
||||||
|
export function openSkinSiteLogin() {
|
||||||
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSkinSiteSession() {
|
||||||
|
user.value = null
|
||||||
|
status.value = 'signed-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) {
|
||||||
|
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
|
||||||
|
const data = event.data
|
||||||
|
if (!data || data.type !== 'starlight-skin-session') return false
|
||||||
|
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||||
|
if (data.status === 'signed-in') {
|
||||||
|
if (
|
||||||
|
!data.user ||
|
||||||
|
typeof data.user.uuid !== 'string' ||
|
||||||
|
typeof data.user.username !== 'string' ||
|
||||||
|
!data.user.uuid ||
|
||||||
|
!data.user.username ||
|
||||||
|
data.user.username.length > 256
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
user.value = { uuid: data.user.uuid, username: data.user.username }
|
||||||
|
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
||||||
|
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
status.value = data.status
|
||||||
|
return true
|
||||||
|
}
|
||||||
@ -114,8 +114,6 @@ export interface DropImportOptions {
|
|||||||
onSchematicWorkshopPage: ComputedRef<boolean>
|
onSchematicWorkshopPage: ComputedRef<boolean>
|
||||||
/** Check if path is a schematic file */
|
/** Check if path is a schematic file */
|
||||||
isSchematicFile: (path: string) => boolean
|
isSchematicFile: (path: string) => boolean
|
||||||
/** Track analytics event */
|
|
||||||
trackEvent: (name: string, properties?: Record<string, unknown>) => void
|
|
||||||
/** Route to push */
|
/** Route to push */
|
||||||
router: Router
|
router: Router
|
||||||
}
|
}
|
||||||
@ -135,7 +133,6 @@ export interface DropImportOptions {
|
|||||||
* onSkinsPage,
|
* onSkinsPage,
|
||||||
* onSchematicWorkshopPage,
|
* onSchematicWorkshopPage,
|
||||||
* isSchematicFile,
|
* isSchematicFile,
|
||||||
* trackEvent,
|
|
||||||
* router,
|
* router,
|
||||||
* route,
|
* route,
|
||||||
* })
|
* })
|
||||||
@ -151,7 +148,6 @@ export function useDropImport(options: DropImportOptions) {
|
|||||||
onSkinsPage,
|
onSkinsPage,
|
||||||
onSchematicWorkshopPage,
|
onSchematicWorkshopPage,
|
||||||
isSchematicFile,
|
isSchematicFile,
|
||||||
trackEvent,
|
|
||||||
router,
|
router,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@ -906,7 +902,6 @@ export function useDropImport(options: DropImportOptions) {
|
|||||||
|
|
||||||
clearDropProcessingNotification()
|
clearDropProcessingNotification()
|
||||||
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
||||||
trackEvent('InstanceCreate', { source: 'DropConfirmModpack' })
|
|
||||||
await router.push('/library')
|
await router.push('/library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_jre, test_jre } from '@/helpers/jre.js'
|
import { get_jre, test_jre } from '@/helpers/jre.js'
|
||||||
|
|
||||||
export default function useJavaTest() {
|
export default function useJavaTest() {
|
||||||
@ -8,7 +7,7 @@ export default function useJavaTest() {
|
|||||||
const javaTestResult = ref<boolean | null>(null)
|
const javaTestResult = ref<boolean | null>(null)
|
||||||
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function runJavaTest(path: string, version: number | null, track = true) {
|
async function runJavaTest(path: string, version: number | null) {
|
||||||
if (testDebounceTimer) {
|
if (testDebounceTimer) {
|
||||||
clearTimeout(testDebounceTimer)
|
clearTimeout(testDebounceTimer)
|
||||||
testDebounceTimer = null
|
testDebounceTimer = null
|
||||||
@ -28,10 +27,6 @@ export default function useJavaTest() {
|
|||||||
javaTestResult.value = false
|
javaTestResult.value = false
|
||||||
}
|
}
|
||||||
testingJava.value = false
|
testingJava.value = false
|
||||||
|
|
||||||
if (track) {
|
|
||||||
trackEvent('JavaTest', { path, success: javaTestResult.value })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
|
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
|
||||||
@ -43,8 +38,8 @@ export default function useJavaTest() {
|
|||||||
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testJavaInstallation(path: string, version: number | null, track = false) {
|
async function testJavaInstallation(path: string, version: number | null) {
|
||||||
await runJavaTest(path, version, track)
|
await runJavaTest(path, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
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('[Axolotl telemetry disabled]', eventName, args[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -652,10 +652,6 @@ export async function update_project(instanceId: string, projectPath: string): P
|
|||||||
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
|
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a project to an instance from a version
|
|
||||||
// Returns a path to the new project file
|
|
||||||
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
|
|
||||||
|
|
||||||
export interface ResolutionPreferences {
|
export interface ResolutionPreferences {
|
||||||
game_versions?: string[]
|
game_versions?: string[]
|
||||||
loaders?: string[]
|
loaders?: string[]
|
||||||
@ -691,14 +687,10 @@ export interface ResolveContentPlan {
|
|||||||
export async function add_project_from_version(
|
export async function add_project_from_version(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
reason: DownloadReason,
|
|
||||||
dependentOnVersionId?: string,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await invoke('plugin:instance|instance_add_project_from_version', {
|
return await invoke('plugin:instance|instance_add_project_from_version', {
|
||||||
instanceId,
|
instanceId,
|
||||||
versionId,
|
versionId,
|
||||||
reason,
|
|
||||||
dependentOnVersionId,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -175,9 +175,6 @@ export type AppSettings = {
|
|||||||
home_widgets: HomeDashboardConfig | null
|
home_widgets: HomeDashboardConfig | null
|
||||||
terracotta_public_nodes: string[]
|
terracotta_public_nodes: string[]
|
||||||
|
|
||||||
telemetry: boolean
|
|
||||||
telemetry_consent_version: number
|
|
||||||
discord_rpc: boolean
|
|
||||||
onboarded: boolean
|
onboarded: boolean
|
||||||
onboarding_version: number
|
onboarding_version: number
|
||||||
onboarding_instance_tour_completed: boolean
|
onboarding_instance_tour_completed: boolean
|
||||||
@ -207,12 +204,6 @@ export type AppSettings = {
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivacySettings = {
|
|
||||||
telemetry: boolean
|
|
||||||
discord_rpc: boolean
|
|
||||||
consent_version: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type LegacyMirrorSettings = {
|
type LegacyMirrorSettings = {
|
||||||
use_minecraft_mirror?: boolean
|
use_minecraft_mirror?: boolean
|
||||||
use_modrinth_mirror?: boolean
|
use_modrinth_mirror?: boolean
|
||||||
@ -301,22 +292,6 @@ export async function cancel_directory_change(): Promise<void> {
|
|||||||
return await invoke('plugin:settings|cancel_directory_change')
|
return await invoke('plugin:settings|cancel_directory_change')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPrivacySettings(): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|privacy_get')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function savePrivacySettings(privacy: PrivacySettings): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|privacy_set', { privacy })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setTelemetryEnabled(enabled: boolean): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|telemetry_set', { enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setDiscordRpcEnabled(enabled: boolean): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|discord_rpc_set', { enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProxyConfig(): Promise<ProxyConfig> {
|
export async function getProxyConfig(): Promise<ProxyConfig> {
|
||||||
return await invoke('plugin:settings|proxy_get')
|
return await invoke('plugin:settings|proxy_get')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,10 +44,6 @@ export async function initialize_state() {
|
|||||||
return await invoke<void>('initialize_state')
|
return await invoke<void>('initialize_state')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function set_discord_activity(activity: string) {
|
|
||||||
return await invoke<void>('set_discord_activity', { activity })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gets active progress bars
|
// Gets active progress bars
|
||||||
export async function progress_bars_list() {
|
export async function progress_bars_list() {
|
||||||
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
|
||||||
|
|
||||||
export function installTelemetryHandlers(): void {
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
void invoke('plugin:telemetry|notify_online').catch(() => undefined)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
2
apps/app-frontend/src/helpers/types.d.ts
vendored
2
apps/app-frontend/src/helpers/types.d.ts
vendored
@ -216,8 +216,6 @@ type AppSettings = {
|
|||||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||||
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
|
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
|
||||||
|
|
||||||
telemetry: boolean
|
|
||||||
discord_rpc: boolean
|
|
||||||
developer_mode: boolean
|
developer_mode: boolean
|
||||||
|
|
||||||
onboarded: boolean
|
onboarded: boolean
|
||||||
|
|||||||
@ -1,4 +1,113 @@
|
|||||||
{
|
{
|
||||||
|
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
|
||||||
|
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
|
||||||
|
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
|
||||||
|
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
|
||||||
|
"app.easteregg.color-mine.title": {
|
||||||
|
"message": "Starlight Mine: Chromatic Realms"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.easy": {
|
||||||
|
"message": "Easy"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.normal": {
|
||||||
|
"message": "Normal"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hard": {
|
||||||
|
"message": "Hard"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.newGame": {
|
||||||
|
"message": "New board"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.close": {
|
||||||
|
"message": "Close"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.choose": {
|
||||||
|
"message": "Click a revealed tile to sample its color, then paint a gray tile."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.selected": {
|
||||||
|
"message": "Color {color} selected · Click a colored tile to change your brush."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rulesTitle": {
|
||||||
|
"message": "How to play"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rules": {
|
||||||
|
"message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.loading": {
|
||||||
|
"message": "Generating regions and checking the deduction path…"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.generateError": {
|
||||||
|
"message": "Could not generate this board. Try again."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.retry": {
|
||||||
|
"message": "Try again"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.progress": {
|
||||||
|
"message": "{count} / {total} revealed"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.best": {
|
||||||
|
"message": "Best · {time}"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.won": {
|
||||||
|
"message": "All colors restored!"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.lost": {
|
||||||
|
"message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.review": {
|
||||||
|
"message": "Unrevealed answers are now shown faded for review."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveTitle": {
|
||||||
|
"message": "Leave this board?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveText": {
|
||||||
|
"message": "Save to continue later, or discard this unfinished board."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.saveLeave": {
|
||||||
|
"message": "Save and leave"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.discard": {
|
||||||
|
"message": "Discard"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.continue": {
|
||||||
|
"message": "Keep playing"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceTitle": {
|
||||||
|
"message": "Start a new board?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceText": {
|
||||||
|
"message": "The current board will be discarded."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.savedTitle": {
|
||||||
|
"message": "An unfinished board is saved"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.resume": {
|
||||||
|
"message": "Resume saved game"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.storageError": {
|
||||||
|
"message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.recordError": {
|
||||||
|
"message": "Finished, but the best time could not be saved."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomIn": {
|
||||||
|
"message": "Zoom in"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomOut": {
|
||||||
|
"message": "Zoom out"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.map": {
|
||||||
|
"message": "Board overview. Click to navigate."
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.board": {
|
||||||
|
"message": "{size} by {size} color deduction board"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hidden": {
|
||||||
|
"message": "Row {row}, column {column}, unrevealed"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.revealed": {
|
||||||
|
"message": "Row {row}, column {column}, color {color}, {number} same-color neighbors"
|
||||||
|
},
|
||||||
"app.account.signed-in-as": {
|
"app.account.signed-in-as": {
|
||||||
"message": "Signed in as"
|
"message": "Signed in as"
|
||||||
},
|
},
|
||||||
@ -5174,8 +5283,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "View Mod changes"
|
"message": "View Mod changes"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "Playing as"
|
"message": "User information"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "Content required"
|
"message": "Content required"
|
||||||
@ -5807,12 +5916,6 @@
|
|||||||
"app.onboarding.mascot-alt": {
|
"app.onboarding.mascot-alt": {
|
||||||
"message": "Starlight guide"
|
"message": "Starlight guide"
|
||||||
},
|
},
|
||||||
"app.onboarding.privacy.description": {
|
|
||||||
"message": "Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to."
|
|
||||||
},
|
|
||||||
"app.onboarding.privacy.title": {
|
|
||||||
"message": "Your data, your call"
|
|
||||||
},
|
|
||||||
"app.onboarding.resources.description": {
|
"app.onboarding.resources.description": {
|
||||||
"message": "Choose how content downloads and installs, from download sources to safety checks."
|
"message": "Choose how content downloads and installs, from download sources to safety checks."
|
||||||
},
|
},
|
||||||
@ -5858,30 +5961,6 @@
|
|||||||
"app.onboarding.welcome.title": {
|
"app.onboarding.welcome.title": {
|
||||||
"message": "Everything is ready"
|
"message": "Everything is ready"
|
||||||
},
|
},
|
||||||
"app.privacy-consent.continue": {
|
|
||||||
"message": "Save and continue"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc": {
|
|
||||||
"message": "Discord Rich Presence"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc-description": {
|
|
||||||
"message": "Shows your current launcher or game activity in Discord when Discord is running."
|
|
||||||
},
|
|
||||||
"app.privacy-consent.intro": {
|
|
||||||
"message": "Choose what Starlight may send or display. Nothing is sent until you confirm these choices."
|
|
||||||
},
|
|
||||||
"app.privacy-consent.privacy-policy": {
|
|
||||||
"message": "Read the privacy policy"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry": {
|
|
||||||
"message": "Allow anonymous telemetry"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry-description": {
|
|
||||||
"message": "Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded."
|
|
||||||
},
|
|
||||||
"app.privacy-consent.title": {
|
|
||||||
"message": "Privacy & security"
|
|
||||||
},
|
|
||||||
"app.project.curseforge.loading": {
|
"app.project.curseforge.loading": {
|
||||||
"message": "Loading CurseForge project…"
|
"message": "Loading CurseForge project…"
|
||||||
},
|
},
|
||||||
@ -6701,9 +6780,6 @@
|
|||||||
"app.settings.about.preview-minecraft-crash-modal": {
|
"app.settings.about.preview-minecraft-crash-modal": {
|
||||||
"message": "Preview Minecraft crash window"
|
"message": "Preview Minecraft crash window"
|
||||||
},
|
},
|
||||||
"app.settings.about.preview-privacy-consent-modal": {
|
|
||||||
"message": "Preview privacy & security modal"
|
|
||||||
},
|
|
||||||
"app.settings.about.product-title": {
|
"app.settings.about.product-title": {
|
||||||
"message": "About {productName}"
|
"message": "About {productName}"
|
||||||
},
|
},
|
||||||
@ -6965,21 +7041,6 @@
|
|||||||
"app.settings.java.view-installed": {
|
"app.settings.java.view-installed": {
|
||||||
"message": "View installed Java"
|
"message": "View installed Java"
|
||||||
},
|
},
|
||||||
"app.settings.privacy.data-handling": {
|
|
||||||
"message": "Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately."
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc": {
|
|
||||||
"message": "Discord Rich Presence"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc-description": {
|
|
||||||
"message": "Show your current launcher or game activity in Discord."
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry": {
|
|
||||||
"message": "Allow telemetry"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry-description": {
|
|
||||||
"message": "Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded."
|
|
||||||
},
|
|
||||||
"app.settings.resources.add-minecraft-directory": {
|
"app.settings.resources.add-minecraft-directory": {
|
||||||
"message": "Add .minecraft directory"
|
"message": "Add .minecraft directory"
|
||||||
},
|
},
|
||||||
@ -7361,9 +7422,6 @@
|
|||||||
"app.settings.tabs.network-multiplayer": {
|
"app.settings.tabs.network-multiplayer": {
|
||||||
"message": "Network & multiplayer"
|
"message": "Network & multiplayer"
|
||||||
},
|
},
|
||||||
"app.settings.tabs.privacy-data": {
|
|
||||||
"message": "Privacy & data sharing"
|
|
||||||
},
|
|
||||||
"app.settings.tabs.storage": {
|
"app.settings.tabs.storage": {
|
||||||
"message": "Storage"
|
"message": "Storage"
|
||||||
},
|
},
|
||||||
@ -10020,13 +10078,13 @@
|
|||||||
"message": "World is in use"
|
"message": "World is in use"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "Add Microsoft account"
|
"message": "Add your own Minecraft account"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "Add offline account"
|
"message": "Add offline account"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "Add third-party account"
|
"message": "Sign in to StarLight Skin Site"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "Copy UUID"
|
"message": "Copy UUID"
|
||||||
|
|||||||
@ -1,22 +1,149 @@
|
|||||||
{
|
{
|
||||||
"app.settings.developer.announcement-preview": { "message": "公告样式预览" },
|
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
|
||||||
|
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
|
||||||
|
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
|
||||||
|
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
|
||||||
|
"app.easteregg.color-mine.title": {
|
||||||
|
"message": "星光矿井:彩域"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.easy": {
|
||||||
|
"message": "简单"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.normal": {
|
||||||
|
"message": "普通"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hard": {
|
||||||
|
"message": "困难"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.newGame": {
|
||||||
|
"message": "新的一局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.close": {
|
||||||
|
"message": "关闭"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.choose": {
|
||||||
|
"message": "先点击已揭示的色块取色,再给灰格染色。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.selected": {
|
||||||
|
"message": "已取色 {color} · 点击其他色块切换画笔。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rulesTitle": {
|
||||||
|
"message": "玩法说明"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rules": {
|
||||||
|
"message": "数字表示周围八格中同色格的数量,不含自身。每种颜色必须组成一个上下左右连通的区域,斜角不算连接。点击已揭示的格子取色,染对会显示新数字,染错则本局结束。每局均经过逐步推理验证,无需猜测。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.loading": {
|
||||||
|
"message": "正在生成色域并校验推理路径…"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.generateError": {
|
||||||
|
"message": "棋盘生成失败,请重试。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.retry": {
|
||||||
|
"message": "重试"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.progress": {
|
||||||
|
"message": "已揭示 {count} / {total}"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.best": {
|
||||||
|
"message": "最佳 · {time}"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.won": {
|
||||||
|
"message": "全部色域已还原!"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.lost": {
|
||||||
|
"message": "第 {row} 行、第 {column} 列染错了:选择了 {selected},正确颜色为 {correct}。本局结束。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.review": {
|
||||||
|
"message": "未揭示的答案已淡色显示,可查看棋盘复盘。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveTitle": {
|
||||||
|
"message": "要离开当前棋盘吗?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveText": {
|
||||||
|
"message": "可以保存,下次从这里继续;也可以放弃本局。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.saveLeave": {
|
||||||
|
"message": "保存并离开"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.discard": {
|
||||||
|
"message": "放弃本局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.continue": {
|
||||||
|
"message": "继续这局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceTitle": {
|
||||||
|
"message": "开始新的一局?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceText": {
|
||||||
|
"message": "当前这局的进度将被放弃。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.savedTitle": {
|
||||||
|
"message": "有一局尚未完成的棋盘"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.resume": {
|
||||||
|
"message": "继续上次的棋盘"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.storageError": {
|
||||||
|
"message": "无法读取或保存存档,当前棋盘已保留。可重试,或选择放弃存档。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.recordError": {
|
||||||
|
"message": "已完成,但最佳成绩未能保存。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomIn": {
|
||||||
|
"message": "放大"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomOut": {
|
||||||
|
"message": "缩小"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.map": {
|
||||||
|
"message": "棋盘总览,点击定位"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.board": {
|
||||||
|
"message": "{size} × {size} 彩色推理棋盘"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hidden": {
|
||||||
|
"message": "第 {row} 行,第 {column} 列,未揭示"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.revealed": {
|
||||||
|
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
|
||||||
|
},
|
||||||
|
"app.settings.developer.announcement-preview": {
|
||||||
|
"message": "公告样式预览"
|
||||||
|
},
|
||||||
"app.settings.developer.announcement-preview-description": {
|
"app.settings.developer.announcement-preview-description": {
|
||||||
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
|
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
|
||||||
},
|
},
|
||||||
"app.settings.developer.preview-with-action": { "message": "显示可选的外部链接按钮" },
|
"app.settings.developer.preview-with-action": {
|
||||||
"app.settings.developer.preview-announcement-modal": { "message": "预览启动弹窗" },
|
"message": "显示可选的外部链接按钮"
|
||||||
"app.settings.developer.preview-announcement-popup": { "message": "预览 Popup 通知" },
|
},
|
||||||
"app.remote-announcements.preview-title": { "message": "公告样式预览" },
|
"app.settings.developer.preview-announcement-modal": {
|
||||||
|
"message": "预览启动弹窗"
|
||||||
|
},
|
||||||
|
"app.settings.developer.preview-announcement-popup": {
|
||||||
|
"message": "预览 Popup 通知"
|
||||||
|
},
|
||||||
|
"app.remote-announcements.preview-title": {
|
||||||
|
"message": "公告样式预览"
|
||||||
|
},
|
||||||
"app.remote-announcements.preview-summary": {
|
"app.remote-announcements.preview-summary": {
|
||||||
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
|
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
|
||||||
},
|
},
|
||||||
"app.remote-announcements.preview-action": { "message": "访问官网" },
|
"app.remote-announcements.preview-action": {
|
||||||
|
"message": "访问官网"
|
||||||
|
},
|
||||||
"app.remote-announcements.preview-content": {
|
"app.remote-announcements.preview-content": {
|
||||||
"message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)"
|
"message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)"
|
||||||
},
|
},
|
||||||
"app.remote-announcements.view": { "message": "查看公告" },
|
"app.remote-announcements.view": {
|
||||||
"app.remote-announcements.unread": { "message": "未读" },
|
"message": "查看公告"
|
||||||
"app.remote-announcements.read-all": { "message": "将所有公告标为已读" },
|
},
|
||||||
|
"app.remote-announcements.unread": {
|
||||||
|
"message": "未读"
|
||||||
|
},
|
||||||
|
"app.remote-announcements.read-all": {
|
||||||
|
"message": "将所有公告标为已读"
|
||||||
|
},
|
||||||
"app.account.signed-in-as": {
|
"app.account.signed-in-as": {
|
||||||
"message": "登录身份:"
|
"message": "登录身份:"
|
||||||
},
|
},
|
||||||
@ -5246,8 +5373,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "查看 Mod 变化"
|
"message": "查看 Mod 变化"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "当前游玩账号:"
|
"message": "用户信息"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "需求内容"
|
"message": "需求内容"
|
||||||
@ -5879,12 +6006,6 @@
|
|||||||
"app.onboarding.mascot-alt": {
|
"app.onboarding.mascot-alt": {
|
||||||
"message": "引导吉祥物"
|
"message": "引导吉祥物"
|
||||||
},
|
},
|
||||||
"app.onboarding.privacy.description": {
|
|
||||||
"message": "匿名遥测、Discord 活动状态与 Minecraft 日志分析服务都可以在这里调整。"
|
|
||||||
},
|
|
||||||
"app.onboarding.privacy.title": {
|
|
||||||
"message": "你的数据,由你决定"
|
|
||||||
},
|
|
||||||
"app.onboarding.resources.description": {
|
"app.onboarding.resources.description": {
|
||||||
"message": "选择内容的下载与安装方式,包括下载源和安全确认。"
|
"message": "选择内容的下载与安装方式,包括下载源和安全确认。"
|
||||||
},
|
},
|
||||||
@ -5930,30 +6051,6 @@
|
|||||||
"app.onboarding.welcome.title": {
|
"app.onboarding.welcome.title": {
|
||||||
"message": "一切就绪"
|
"message": "一切就绪"
|
||||||
},
|
},
|
||||||
"app.privacy-consent.continue": {
|
|
||||||
"message": "保存并继续"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc": {
|
|
||||||
"message": "Discord 活动状态"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc-description": {
|
|
||||||
"message": "Discord 运行时显示你当前使用启动器或游玩的状态。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.intro": {
|
|
||||||
"message": "选择 Starlight 可以发送或展示的内容。确认前不会发送任何遥测。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.privacy-policy": {
|
|
||||||
"message": "阅读隐私政策"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry": {
|
|
||||||
"message": "允许匿名遥测"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry-description": {
|
|
||||||
"message": "用于统计选择加入的安装量和每日活跃用户。绝不上传完整 Minecraft 日志或账户凭据。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.title": {
|
|
||||||
"message": "隐私与安全"
|
|
||||||
},
|
|
||||||
"app.project.curseforge.loading": {
|
"app.project.curseforge.loading": {
|
||||||
"message": "正在加载 CurseForge 项目…"
|
"message": "正在加载 CurseForge 项目…"
|
||||||
},
|
},
|
||||||
@ -6746,9 +6843,6 @@
|
|||||||
"app.settings.about.preview-minecraft-crash-modal": {
|
"app.settings.about.preview-minecraft-crash-modal": {
|
||||||
"message": "预览 Minecraft 崩溃窗口"
|
"message": "预览 Minecraft 崩溃窗口"
|
||||||
},
|
},
|
||||||
"app.settings.about.preview-privacy-consent-modal": {
|
|
||||||
"message": "预览隐私与安全弹窗"
|
|
||||||
},
|
|
||||||
"app.settings.about.product-title": {
|
"app.settings.about.product-title": {
|
||||||
"message": "关于 {productName}"
|
"message": "关于 {productName}"
|
||||||
},
|
},
|
||||||
@ -6995,21 +7089,6 @@
|
|||||||
"app.settings.java.view-installed": {
|
"app.settings.java.view-installed": {
|
||||||
"message": "查看已安装 Java"
|
"message": "查看已安装 Java"
|
||||||
},
|
},
|
||||||
"app.settings.privacy.data-handling": {
|
|
||||||
"message": "遥测使用随机安装标识,仅发送每日活跃信号。关闭遥测会立即清空待发送数据。"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc": {
|
|
||||||
"message": "Discord 活动状态"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc-description": {
|
|
||||||
"message": "在 Discord 中显示你当前使用启动器或游玩的状态。"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry": {
|
|
||||||
"message": "允许遥测"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry-description": {
|
|
||||||
"message": "发送匿名每日活跃信号以优化使用统计。绝不上传 Minecraft 日志或账户凭据。"
|
|
||||||
},
|
|
||||||
"app.settings.resources.app-cache": {
|
"app.settings.resources.app-cache": {
|
||||||
"message": "应用缓存"
|
"message": "应用缓存"
|
||||||
},
|
},
|
||||||
@ -7400,9 +7479,6 @@
|
|||||||
"app.settings.tabs.network-multiplayer": {
|
"app.settings.tabs.network-multiplayer": {
|
||||||
"message": "网络与多人游戏"
|
"message": "网络与多人游戏"
|
||||||
},
|
},
|
||||||
"app.settings.tabs.privacy-data": {
|
|
||||||
"message": "隐私与数据共享"
|
|
||||||
},
|
|
||||||
"app.settings.tabs.resource-management": {
|
"app.settings.tabs.resource-management": {
|
||||||
"message": "资源管理"
|
"message": "资源管理"
|
||||||
},
|
},
|
||||||
@ -10083,13 +10159,13 @@
|
|||||||
"message": "世界正在使用中"
|
"message": "世界正在使用中"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "添加 Microsoft 账户"
|
"message": "添加自己的正版账号"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "添加离线账户"
|
"message": "添加离线账户"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "添加第三方账号"
|
"message": "登录 StarLight皮肤站"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "复制 UUID"
|
"message": "复制 UUID"
|
||||||
@ -10309,6 +10385,5 @@
|
|||||||
},
|
},
|
||||||
"app.settings.about.easteregg.game-title": {
|
"app.settings.about.easteregg.game-title": {
|
||||||
"message": "彩蛋小游戏"
|
"message": "彩蛋小游戏"
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,113 @@
|
|||||||
{
|
{
|
||||||
|
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
|
||||||
|
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
|
||||||
|
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
|
||||||
|
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
|
||||||
|
"app.easteregg.color-mine.title": {
|
||||||
|
"message": "星光礦井:彩域"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.easy": {
|
||||||
|
"message": "簡單"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.normal": {
|
||||||
|
"message": "普通"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hard": {
|
||||||
|
"message": "困難"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.newGame": {
|
||||||
|
"message": "新的一局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.close": {
|
||||||
|
"message": "關閉"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.choose": {
|
||||||
|
"message": "先點擊已揭示的色塊取色,再為灰格染色。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.selected": {
|
||||||
|
"message": "已取色 {color} · 點擊其他色塊切換畫筆。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rulesTitle": {
|
||||||
|
"message": "玩法說明"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.rules": {
|
||||||
|
"message": "數字表示周圍八格中同色格的數量,不含自身。每種顏色必須組成一個上下左右連通的區域,斜角不算連接。點擊已揭示的格子取色,染對會顯示新數字,染錯則本局結束。每局均經過逐步推理驗證,無需猜測。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.loading": {
|
||||||
|
"message": "正在生成色域並校驗推理路徑…"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.generateError": {
|
||||||
|
"message": "棋盤生成失敗,請重試。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.retry": {
|
||||||
|
"message": "重試"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.progress": {
|
||||||
|
"message": "已揭示 {count} / {total}"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.best": {
|
||||||
|
"message": "最佳 · {time}"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.won": {
|
||||||
|
"message": "全部色域已還原!"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.lost": {
|
||||||
|
"message": "第 {row} 行、第 {column} 列染錯了:選擇了 {selected},正確顏色為 {correct}。本局結束。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.review": {
|
||||||
|
"message": "未揭示的答案已淡色顯示,可查看棋盤複盤。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveTitle": {
|
||||||
|
"message": "要離開目前棋盤嗎?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.leaveText": {
|
||||||
|
"message": "可以儲存,下次從這裡繼續;也可以放棄本局。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.saveLeave": {
|
||||||
|
"message": "儲存並離開"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.discard": {
|
||||||
|
"message": "放棄本局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.continue": {
|
||||||
|
"message": "繼續這局"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceTitle": {
|
||||||
|
"message": "開始新的一局?"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.replaceText": {
|
||||||
|
"message": "目前這局的進度將被放棄。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.savedTitle": {
|
||||||
|
"message": "有一局尚未完成的棋盤"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.resume": {
|
||||||
|
"message": "繼續上次的棋盤"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.storageError": {
|
||||||
|
"message": "無法讀取或儲存存檔,目前棋盤已保留。可重試,或選擇放棄存檔。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.recordError": {
|
||||||
|
"message": "已完成,但最佳成績未能儲存。"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomIn": {
|
||||||
|
"message": "放大"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.zoomOut": {
|
||||||
|
"message": "縮小"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.map": {
|
||||||
|
"message": "棋盤總覽,點擊定位"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.board": {
|
||||||
|
"message": "{size} × {size} 彩色推理棋盤"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.hidden": {
|
||||||
|
"message": "第 {row} 行,第 {column} 列,未揭示"
|
||||||
|
},
|
||||||
|
"app.easteregg.color-mine.revealed": {
|
||||||
|
"message": "第 {row} 行,第 {column} 列,顏色 {color},周圍同色 {number} 格"
|
||||||
|
},
|
||||||
"app.account.signed-in-as": {
|
"app.account.signed-in-as": {
|
||||||
"message": "登入身分:"
|
"message": "登入身分:"
|
||||||
},
|
},
|
||||||
@ -5015,8 +5124,8 @@
|
|||||||
"app.minecraft-crash.view-mod-changes": {
|
"app.minecraft-crash.view-mod-changes": {
|
||||||
"message": "查看 Mod 變更"
|
"message": "查看 Mod 變更"
|
||||||
},
|
},
|
||||||
"app.minecraft.playing-as": {
|
"app.minecraft.user-information": {
|
||||||
"message": "當前遊玩帳號:"
|
"message": "使用者資訊"
|
||||||
},
|
},
|
||||||
"app.modal.install-to-play.content-required": {
|
"app.modal.install-to-play.content-required": {
|
||||||
"message": "所需內容"
|
"message": "所需內容"
|
||||||
@ -5618,12 +5727,6 @@
|
|||||||
"app.onboarding.mascot-alt": {
|
"app.onboarding.mascot-alt": {
|
||||||
"message": "引導吉祥物"
|
"message": "引導吉祥物"
|
||||||
},
|
},
|
||||||
"app.onboarding.privacy.description": {
|
|
||||||
"message": "匿名遙測、Discord 活動狀態與 Minecraft 日誌分析服務都可以在這裡調整。"
|
|
||||||
},
|
|
||||||
"app.onboarding.privacy.title": {
|
|
||||||
"message": "你的資料,由你決定"
|
|
||||||
},
|
|
||||||
"app.onboarding.resources.description": {
|
"app.onboarding.resources.description": {
|
||||||
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
|
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
|
||||||
},
|
},
|
||||||
@ -5669,30 +5772,6 @@
|
|||||||
"app.onboarding.welcome.title": {
|
"app.onboarding.welcome.title": {
|
||||||
"message": "一切就緒"
|
"message": "一切就緒"
|
||||||
},
|
},
|
||||||
"app.privacy-consent.continue": {
|
|
||||||
"message": "儲存並繼續"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc": {
|
|
||||||
"message": "Discord 活動狀態"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.discord-rpc-description": {
|
|
||||||
"message": "Discord 執行時顯示你當前使用啟動器或遊玩的狀態。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.intro": {
|
|
||||||
"message": "選擇 Starlight 可以傳送或展示的內容。確認前不會傳送任何遙測。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.privacy-policy": {
|
|
||||||
"message": "閱讀隱私政策"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry": {
|
|
||||||
"message": "允許匿名遙測"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.telemetry-description": {
|
|
||||||
"message": "透過脫敏且限長的報告統計選擇加入的安裝量並排查啟動器錯誤。不會上傳完整 Minecraft 日誌或賬戶憑據。"
|
|
||||||
},
|
|
||||||
"app.privacy-consent.title": {
|
|
||||||
"message": "隱私與安全"
|
|
||||||
},
|
|
||||||
"app.project.curseforge.loading": {
|
"app.project.curseforge.loading": {
|
||||||
"message": "正在載入 CurseForge 專案..."
|
"message": "正在載入 CurseForge 專案..."
|
||||||
},
|
},
|
||||||
@ -6461,9 +6540,6 @@
|
|||||||
"app.settings.about.preview-minecraft-crash-modal": {
|
"app.settings.about.preview-minecraft-crash-modal": {
|
||||||
"message": "預覽 Minecraft 崩潰窗口"
|
"message": "預覽 Minecraft 崩潰窗口"
|
||||||
},
|
},
|
||||||
"app.settings.about.preview-privacy-consent-modal": {
|
|
||||||
"message": "預覽隱私與安全彈窗"
|
|
||||||
},
|
|
||||||
"app.settings.about.product-title": {
|
"app.settings.about.product-title": {
|
||||||
"message": "關於 {productName}"
|
"message": "關於 {productName}"
|
||||||
},
|
},
|
||||||
@ -6710,21 +6786,6 @@
|
|||||||
"app.settings.java.view-installed": {
|
"app.settings.java.view-installed": {
|
||||||
"message": "查看已安裝 Java"
|
"message": "查看已安裝 Java"
|
||||||
},
|
},
|
||||||
"app.settings.privacy.data-handling": {
|
|
||||||
"message": "遙測使用隨機安裝標識。錯誤上下文離開裝置前會經過脫敏和長度限制。關閉遙測會立即清空待傳送報告。"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc": {
|
|
||||||
"message": "Discord 活動狀態"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.discord-rpc-description": {
|
|
||||||
"message": "在 Discord 中顯示你當前使用啟動器或遊玩的狀態。"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry": {
|
|
||||||
"message": "允許遙測"
|
|
||||||
},
|
|
||||||
"app.settings.privacy.telemetry-description": {
|
|
||||||
"message": "傳送匿名每日活躍訊號和脫敏的啟動器錯誤報告。絕不上傳 Minecraft 日誌或賬戶憑據。"
|
|
||||||
},
|
|
||||||
"app.settings.resources.app-cache": {
|
"app.settings.resources.app-cache": {
|
||||||
"message": "應用程式快取"
|
"message": "應用程式快取"
|
||||||
},
|
},
|
||||||
@ -7115,9 +7176,6 @@
|
|||||||
"app.settings.tabs.network-multiplayer": {
|
"app.settings.tabs.network-multiplayer": {
|
||||||
"message": "網路與多人遊戲"
|
"message": "網路與多人遊戲"
|
||||||
},
|
},
|
||||||
"app.settings.tabs.privacy-data": {
|
|
||||||
"message": "隱私與資料共享"
|
|
||||||
},
|
|
||||||
"app.settings.tabs.resource-management": {
|
"app.settings.tabs.resource-management": {
|
||||||
"message": "資源管理"
|
"message": "資源管理"
|
||||||
},
|
},
|
||||||
@ -9747,13 +9805,13 @@
|
|||||||
"message": "世界正在使用中"
|
"message": "世界正在使用中"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-microsoft-account": {
|
"minecraft-account.add-microsoft-account": {
|
||||||
"message": "新增 Microsoft 帳號"
|
"message": "新增自己的正版帳號"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-offline-account": {
|
"minecraft-account.add-offline-account": {
|
||||||
"message": "新增離線帳號"
|
"message": "新增離線帳號"
|
||||||
},
|
},
|
||||||
"minecraft-account.add-third-party-account": {
|
"minecraft-account.sign-in-starlight": {
|
||||||
"message": "新增第三方帳號"
|
"message": "登入 StarLight皮膚站"
|
||||||
},
|
},
|
||||||
"minecraft-account.copy-uuid": {
|
"minecraft-account.copy-uuid": {
|
||||||
"message": "複製 UUID"
|
"message": "複製 UUID"
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { createApp } from 'vue'
|
|||||||
|
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
|
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
|
||||||
import { installTelemetryHandlers } from '@/helpers/telemetry'
|
|
||||||
import i18nPlugin from '@/plugins/i18n'
|
import i18nPlugin from '@/plugins/i18n'
|
||||||
import i18nDebugPlugin from '@/plugins/i18n-debug'
|
import i18nDebugPlugin from '@/plugins/i18n-debug'
|
||||||
import router from '@/routes'
|
import router from '@/routes'
|
||||||
@ -18,8 +17,6 @@ const pinia = createPinia()
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
installTelemetryHandlers()
|
|
||||||
|
|
||||||
app.use(VueQueryPlugin)
|
app.use(VueQueryPlugin)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|||||||
@ -137,11 +137,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="downloadTelemetry(job).length"
|
v-if="downloadDetails(job).length"
|
||||||
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
|
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="(metric, index) in downloadTelemetry(job)"
|
v-for="(metric, index) in downloadDetails(job)"
|
||||||
:key="`${index}-${metric}`"
|
:key="`${index}-${metric}`"
|
||||||
>
|
>
|
||||||
<BulletDivider v-if="index > 0" />
|
<BulletDivider v-if="index > 0" />
|
||||||
@ -1058,7 +1058,7 @@ function totalRequiredFiles(job: InstallJobSnapshot) {
|
|||||||
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
|
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTelemetry(job: InstallJobSnapshot) {
|
function downloadDetails(job: InstallJobSnapshot) {
|
||||||
const summary = job.summary
|
const summary = job.summary
|
||||||
const metrics: string[] = []
|
const metrics: string[] = []
|
||||||
if (summary.source && !isRecoveryValidation(job)) {
|
if (summary.source && !isRecoveryValidation(job)) {
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
|
||||||
CheckIcon,
|
|
||||||
GridIcon,
|
|
||||||
LayoutTemplateIcon,
|
|
||||||
MinimizeIcon,
|
|
||||||
MoveIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RotateCounterClockwiseIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import {
|
import {
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@ -18,20 +9,14 @@ import {
|
|||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
|
||||||
createDefaultHomeDashboard,
|
|
||||||
createHomeDashboardSaveQueue,
|
|
||||||
type HomeDashboardConfig,
|
|
||||||
normalizeHomeDashboard,
|
|
||||||
} from '@/components/home/home-dashboard'
|
|
||||||
import { getActivePlayerName } from '@/components/home/home-utils'
|
import { getActivePlayerName } from '@/components/home/home-utils'
|
||||||
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
||||||
import HomeDashboard from '@/components/home/HomeDashboard.vue'
|
|
||||||
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
||||||
|
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
||||||
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
||||||
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
||||||
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
|
||||||
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
||||||
|
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { get_default_user, users } from '@/helpers/auth'
|
import { get_default_user, users } from '@/helpers/auth'
|
||||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||||
@ -41,7 +26,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
|||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||||
import { useTheming } from '@/store/state'
|
import { useTheming } from '@/store/state'
|
||||||
import type { FeatureFlag, HomeLayout } from '@/store/theme'
|
import type { HomeLayout } from '@/store/theme'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -66,58 +51,15 @@ const messages = defineMessages({
|
|||||||
id: 'app.home.layout.toggle',
|
id: 'app.home.layout.toggle',
|
||||||
defaultMessage: 'Minimal Home',
|
defaultMessage: 'Minimal Home',
|
||||||
},
|
},
|
||||||
switchToGridWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.layout.switch-to-grid',
|
|
||||||
defaultMessage: 'Switch to grid widget layout',
|
|
||||||
},
|
|
||||||
switchToFreeWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.layout.switch-to-free',
|
|
||||||
defaultMessage: 'Switch to free widget layout',
|
|
||||||
},
|
|
||||||
widgetLayoutToggle: {
|
|
||||||
id: 'app.home.widgets.layout.toggle',
|
|
||||||
defaultMessage: 'Widget layout mode',
|
|
||||||
},
|
|
||||||
resetWidgets: {
|
|
||||||
id: 'app.home.widgets.reset-confirm',
|
|
||||||
defaultMessage: 'Restore the default widget layout?',
|
|
||||||
},
|
|
||||||
customizeWidgets: {
|
|
||||||
id: 'app.home.widgets.customize',
|
|
||||||
defaultMessage: 'Customize widgets',
|
|
||||||
},
|
|
||||||
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
|
|
||||||
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
|
|
||||||
resetWidgetLayout: {
|
|
||||||
id: 'app.home.widgets.reset',
|
|
||||||
defaultMessage: 'Restore default widgets',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
|
||||||
|
|
||||||
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
||||||
|
|
||||||
const instances = ref<GameInstance[]>([])
|
const instances = ref<GameInstance[]>([])
|
||||||
const playerName = ref<string | null>(null)
|
const playerName = ref<string | null>(null)
|
||||||
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
|
|
||||||
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
|
|
||||||
const dashboardEditing = ref(false)
|
|
||||||
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
||||||
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
||||||
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
|
|
||||||
const switchingLayout = ref(false)
|
const switchingLayout = ref(false)
|
||||||
const dashboardSaveQueue = createHomeDashboardSaveQueue(
|
|
||||||
async (config) => {
|
|
||||||
const settings = await getSettings()
|
|
||||||
settings.home_widgets = config
|
|
||||||
await setSettings(settings)
|
|
||||||
},
|
|
||||||
(config) => {
|
|
||||||
dashboardConfig.value = config
|
|
||||||
},
|
|
||||||
handleError,
|
|
||||||
)
|
|
||||||
const floatingControlsStyle = computed(() => ({
|
const floatingControlsStyle = computed(() => ({
|
||||||
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
||||||
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
||||||
@ -162,38 +104,6 @@ async function fetchPlayerName() {
|
|||||||
playerName.value = getActivePlayerName(selectedUser, accounts)
|
playerName.value = getActivePlayerName(selectedUser, accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboardConfig() {
|
|
||||||
try {
|
|
||||||
const settings = await getSettings()
|
|
||||||
const normalized = normalizeHomeDashboard(settings.home_widgets)
|
|
||||||
if (normalized) {
|
|
||||||
dashboardConfig.value = normalized
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
|
|
||||||
dashboardConfig.value = config
|
|
||||||
settings.home_widgets = config
|
|
||||||
await setSettings(settings)
|
|
||||||
} catch (error) {
|
|
||||||
dashboardConfig.value = createDefaultHomeDashboard(
|
|
||||||
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
|
|
||||||
)
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDashboardConfig(config: HomeDashboardConfig) {
|
|
||||||
const previous = dashboardConfig.value ?? config
|
|
||||||
dashboardConfig.value = config
|
|
||||||
void dashboardSaveQueue.enqueue(config, previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDashboardConfig() {
|
|
||||||
if (!window.confirm(formatMessage(messages.resetWidgets))) return
|
|
||||||
updateDashboardConfig(createDefaultHomeDashboard())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMinimalInstance(instance: GameInstance) {
|
async function selectMinimalInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
@ -214,10 +124,8 @@ async function toggleHomeLayout() {
|
|||||||
|
|
||||||
const previousLayout = themeStore.homeLayout
|
const previousLayout = themeStore.homeLayout
|
||||||
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
||||||
const previousEditing = dashboardEditing.value
|
|
||||||
switchingLayout.value = true
|
switchingLayout.value = true
|
||||||
themeStore.homeLayout = nextLayout
|
themeStore.homeLayout = nextLayout
|
||||||
if (nextLayout === 'minimal') dashboardEditing.value = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
@ -225,28 +133,14 @@ async function toggleHomeLayout() {
|
|||||||
await setSettings(settings)
|
await setSettings(settings)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
themeStore.homeLayout = previousLayout
|
themeStore.homeLayout = previousLayout
|
||||||
dashboardEditing.value = previousEditing
|
|
||||||
handleError(error)
|
handleError(error)
|
||||||
} finally {
|
} finally {
|
||||||
switchingLayout.value = false
|
switchingLayout.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDashboardEditing() {
|
|
||||||
dashboardEditing.value = !dashboardEditing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWidgetLayout() {
|
|
||||||
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
|
|
||||||
}
|
|
||||||
|
|
||||||
function openWidgetPicker() {
|
|
||||||
dashboard.value?.openWidgetPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
const instancesLoaded = await fetchInstances()
|
const instancesLoaded = await fetchInstances()
|
||||||
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
||||||
await loadDashboardConfig()
|
|
||||||
|
|
||||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
||||||
|
|
||||||
@ -267,16 +161,8 @@ onUnmounted(() => {
|
|||||||
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
||||||
@select="selectMinimalInstance"
|
@select="selectMinimalInstance"
|
||||||
/>
|
/>
|
||||||
<div class="min-h-full">
|
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
|
||||||
<HomeDashboard
|
<SkinSiteFrame v-if="!isMinimal" />
|
||||||
v-if="!isMinimal && dashboardConfig"
|
|
||||||
ref="dashboard"
|
|
||||||
:config="dashboardConfig"
|
|
||||||
:instances="instances"
|
|
||||||
:player-name="playerName"
|
|
||||||
:editing="dashboardEditing"
|
|
||||||
@change="updateDashboardConfig"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HomeMinimal
|
<HomeMinimal
|
||||||
v-else
|
v-else
|
||||||
@ -288,71 +174,6 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-floating-controls" :style="floatingControlsStyle">
|
<div class="home-floating-controls" :style="floatingControlsStyle">
|
||||||
<template v-if="!isMinimal">
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="formatMessage(messages.addWidget)"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:aria-label="formatMessage(messages.addWidget)"
|
|
||||||
@click="openWidgetPicker"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="formatMessage(messages.resetWidgetLayout)"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:aria-label="formatMessage(messages.resetWidgetLayout)"
|
|
||||||
@click="resetDashboardConfig"
|
|
||||||
>
|
|
||||||
<RotateCounterClockwiseIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
|
||||||
"
|
|
||||||
data-onboarding-id="home-widget-customize"
|
|
||||||
type="button"
|
|
||||||
class="home-floating-action"
|
|
||||||
:class="{ 'is-active': dashboardEditing }"
|
|
||||||
:aria-label="
|
|
||||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
|
||||||
"
|
|
||||||
:aria-pressed="dashboardEditing"
|
|
||||||
@click="toggleDashboardEditing"
|
|
||||||
>
|
|
||||||
<CheckIcon v-if="dashboardEditing" />
|
|
||||||
<PencilIcon v-else />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="dashboardEditing"
|
|
||||||
v-tooltip="
|
|
||||||
formatMessage(
|
|
||||||
isFreeWidgetLayout
|
|
||||||
? messages.switchToGridWidgetLayout
|
|
||||||
: messages.switchToFreeWidgetLayout,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
class="home-layout-switch home-widget-layout-switch"
|
|
||||||
:class="{ 'is-free': isFreeWidgetLayout }"
|
|
||||||
:aria-checked="isFreeWidgetLayout"
|
|
||||||
:aria-label="formatMessage(messages.widgetLayoutToggle)"
|
|
||||||
@click="toggleWidgetLayout"
|
|
||||||
>
|
|
||||||
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
|
|
||||||
<GridIcon />
|
|
||||||
</span>
|
|
||||||
<span class="home-layout-switch-thumb" aria-hidden="true" />
|
|
||||||
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
|
|
||||||
<MoveIcon />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<span class="home-floating-divider" aria-hidden="true" />
|
|
||||||
</template>
|
|
||||||
<button
|
<button
|
||||||
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
||||||
data-onboarding-id="home-layout-switch"
|
data-onboarding-id="home-layout-switch"
|
||||||
@ -388,6 +209,17 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.home-content {
|
||||||
|
/* The embedded site owns scrolling within the launcher viewport. */
|
||||||
|
height: calc(100dvh - var(--top-bar-height));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-content.is-minimal {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100dvh - var(--top-bar-height));
|
||||||
|
}
|
||||||
|
|
||||||
.home-floating-controls {
|
.home-floating-controls {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
@ -406,57 +238,11 @@ onUnmounted(() => {
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-floating-action {
|
|
||||||
display: flex;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color 120ms ease,
|
|
||||||
color 120ms ease,
|
|
||||||
filter 150ms ease,
|
|
||||||
transform 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:hover {
|
|
||||||
background: var(--color-button-bg);
|
|
||||||
color: var(--color-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:active {
|
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action:focus-visible,
|
|
||||||
.home-layout-switch:focus-visible {
|
.home-layout-switch:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-floating-action.is-active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: var(--color-accent-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-action :deep(svg) {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-floating-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin: 0 0.125rem;
|
|
||||||
background: var(--color-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-layout-switch {
|
.home-layout-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -505,10 +291,6 @@ onUnmounted(() => {
|
|||||||
transform: translateX(2rem);
|
transform: translateX(2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
|
|
||||||
transform: translateX(2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-layout-switch-option {
|
.home-layout-switch-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -527,9 +309,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
||||||
.home-layout-switch.is-minimal .home-layout-switch-minimal,
|
.home-layout-switch.is-minimal .home-layout-switch-minimal {
|
||||||
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
|
|
||||||
.home-widget-layout-switch.is-free .home-widget-layout-free {
|
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,41 +1,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: {
|
|
||||||
id: 'app.starlight-skin.title',
|
|
||||||
defaultMessage: '斯达莱特',
|
|
||||||
},
|
|
||||||
frameTitle: {
|
|
||||||
id: 'app.starlight-skin.frame-title',
|
|
||||||
defaultMessage: 'StarLight Skin Site',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const SKIN_SITE_URL = 'https://skin.starlight.cool/'
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
function onLoad() {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="h-full min-h-0">
|
||||||
<div v-if="loading" class="flex flex-1 items-center justify-center">
|
<SkinSiteFrame />
|
||||||
<span class="text-secondary">{{ formatMessage(messages.title) }}…</span>
|
|
||||||
</div>
|
|
||||||
<iframe
|
|
||||||
:src="SKIN_SITE_URL"
|
|
||||||
:title="formatMessage(messages.frameTitle)"
|
|
||||||
class="h-full min-h-0 w-full flex-1 border-0"
|
|
||||||
:class="loading ? 'hidden' : ''"
|
|
||||||
@load="onLoad"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -419,7 +419,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
|||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
||||||
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
|
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project_v3 } from '@/helpers/cache.js'
|
import { get_project_v3 } from '@/helpers/cache.js'
|
||||||
import { instance_listener, process_listener } from '@/helpers/events'
|
import { instance_listener, process_listener } from '@/helpers/events'
|
||||||
import {
|
import {
|
||||||
@ -776,11 +775,6 @@ const startInstance = async (context: string) => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopInstance = async (context: string) => {
|
const stopInstance = async (context: string) => {
|
||||||
@ -790,11 +784,6 @@ const stopInstance = async (context: string) => {
|
|||||||
playing.value = false
|
playing.value = false
|
||||||
|
|
||||||
if (!instance.value) return
|
if (!instance.value) return
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayServer = async () => {
|
const handlePlayServer = async () => {
|
||||||
|
|||||||
@ -264,7 +264,6 @@ import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleD
|
|||||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||||
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
||||||
import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
|
import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
|
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
|
||||||
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
|
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
|
||||||
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
|
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
|
||||||
@ -1580,14 +1579,6 @@ async function toggleDisableMod(
|
|||||||
const enabled = desiredEnabled ?? !mod.enabled
|
const enabled = desiredEnabled ?? !mod.enabled
|
||||||
try {
|
try {
|
||||||
await toggleWorldDatapackItem(mod, enabled)
|
await toggleWorldDatapackItem(mod, enabled)
|
||||||
trackEvent('InstanceProjectDisable', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
disabled: !enabled,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
}
|
}
|
||||||
@ -1646,14 +1637,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
|
|||||||
enabled: actualEnabled,
|
enabled: actualEnabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
trackEvent('InstanceProjectDisable', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
disabled: !actualEnabled,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
|
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
|
||||||
file_path: originalFilePath,
|
file_path: originalFilePath,
|
||||||
@ -1733,14 +1716,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
trackEvent('InstanceProjectDisable', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: operation.item.project?.id,
|
|
||||||
name: operation.item.project?.title ?? operation.originalFileName,
|
|
||||||
project_type: operation.item.project_type,
|
|
||||||
disabled: !enabled,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
@ -1831,13 +1806,6 @@ async function removeMod(mod: ContentItem) {
|
|||||||
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('InstanceProjectRemove', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -1999,13 +1967,6 @@ async function updateProject(mod: ContentItem) {
|
|||||||
try {
|
try {
|
||||||
await update_content_entry(props.instance.id, contentId)
|
await update_content_entry(props.instance.id, contentId)
|
||||||
|
|
||||||
trackEvent('InstanceProjectUpdate', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
throw err
|
throw err
|
||||||
@ -2024,13 +1985,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
|||||||
try {
|
try {
|
||||||
await switch_content_entry_version(props.instance.id, contentId, version.id)
|
await switch_content_entry_version(props.instance.id, contentId, version.id)
|
||||||
|
|
||||||
trackEvent('InstanceProjectUpdate', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -2046,13 +2000,6 @@ async function handleRollbackContent(mod: ContentItem) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await rollback_project(props.instance.id, mod.file_path)
|
await rollback_project(props.instance.id, mod.file_path)
|
||||||
trackEvent('InstanceProjectRollback', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
id: mod.project?.id,
|
|
||||||
name: mod.project?.title ?? mod.file_name,
|
|
||||||
project_type: mod.project_type,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -204,7 +204,6 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
|
|||||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
||||||
import { instance_listener } from '@/helpers/events'
|
import { instance_listener } from '@/helpers/events'
|
||||||
import { get_game_versions } from '@/helpers/tags'
|
import { get_game_versions } from '@/helpers/tags'
|
||||||
@ -592,20 +591,10 @@ async function joinWorld(world: World) {
|
|||||||
const managedProjectId = instance.value.link?.project_id
|
const managedProjectId = instance.value.link?.project_id
|
||||||
if (managedProjectId && isManagedServerWorld(world)) {
|
if (managedProjectId && isManagedServerWorld(world)) {
|
||||||
await playServerProject(managedProjectId).catch(handleJoinError)
|
await playServerProject(managedProjectId).catch(handleJoinError)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: 'WorldsPage',
|
|
||||||
})
|
|
||||||
startingInstance.value = false
|
startingInstance.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
|
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: 'WorldsPage',
|
|
||||||
})
|
|
||||||
} else if (world.type === 'singleplayer') {
|
} else if (world.type === 'singleplayer') {
|
||||||
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
type ProjectGalleryCaptionField,
|
type ProjectGalleryCaptionField,
|
||||||
projectGalleryTranslationSegmentId,
|
projectGalleryTranslationSegmentId,
|
||||||
@ -130,10 +129,6 @@ function viewImage(entry: GalleryEntry) {
|
|||||||
zoomedIn.value = false
|
zoomedIn.value = false
|
||||||
viewerModal.value?.show()
|
viewerModal.value?.show()
|
||||||
|
|
||||||
trackEvent('GalleryImageExpand', {
|
|
||||||
project_id: props.project.id,
|
|
||||||
url: entry.image.url,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeImage(offset: number) {
|
function changeImage(offset: number) {
|
||||||
@ -146,10 +141,6 @@ function changeImage(offset: number) {
|
|||||||
selectedGalleryItem.value = filteredGallery.value[nextIndex]
|
selectedGalleryItem.value = filteredGallery.value[nextIndex]
|
||||||
zoomedIn.value = false
|
zoomedIn.value = false
|
||||||
|
|
||||||
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
|
|
||||||
project_id: props.project.id,
|
|
||||||
url: selectedGalleryItem.value.image.url,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewerHide() {
|
function handleViewerHide() {
|
||||||
|
|||||||
@ -210,25 +210,6 @@
|
|||||||
{{ installButtonLabel }}
|
{{ installButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<!-- 开服功能暂有问题,隐藏该按钮
|
|
||||||
<Transition name="start-server">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="serverCapableModpack"
|
|
||||||
key="modpack-start-server"
|
|
||||||
size="large"
|
|
||||||
type="outlined"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.startServer)"
|
|
||||||
type="button"
|
|
||||||
@click="openModpackServerFlow"
|
|
||||||
>
|
|
||||||
<ServerIcon />
|
|
||||||
{{ formatMessage(messages.startServer) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</Transition>
|
|
||||||
-->
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
<ButtonStyled size="large" circular type="transparent">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:tooltip="`More options`"
|
:tooltip="`More options`"
|
||||||
@ -339,7 +320,6 @@
|
|||||||
:translations="translations"
|
:translations="translations"
|
||||||
:translation-mode="translationMode"
|
:translation-mode="translationMode"
|
||||||
:translation-style="translationStyle"
|
:translation-style="translationStyle"
|
||||||
:start-server="(version) => openModpackServerFlow(version)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else> Project data couldn't not be loaded. </template>
|
<template v-else> Project data couldn't not be loaded. </template>
|
||||||
@ -408,7 +388,6 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReportIcon,
|
ReportIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
@ -424,7 +403,6 @@ import {
|
|||||||
getLatestMatchingInstallVersion,
|
getLatestMatchingInstallVersion,
|
||||||
getTargetInstallPreferences,
|
getTargetInstallPreferences,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
injectPopupNotificationManager,
|
|
||||||
NavTabs,
|
NavTabs,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
ProjectBackgroundGradient,
|
ProjectBackgroundGradient,
|
||||||
@ -501,7 +479,6 @@ import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
|
|||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
const { addNotification, handleError } = injectNotificationManager()
|
const { addNotification, handleError } = injectNotificationManager()
|
||||||
const popupNotificationManager = injectPopupNotificationManager()
|
|
||||||
const { install: installVersion } = injectContentInstall()
|
const { install: installVersion } = injectContentInstall()
|
||||||
const contentSelection = injectContentSelection()
|
const contentSelection = injectContentSelection()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -603,22 +580,6 @@ const messages = defineMessages({
|
|||||||
id: 'project.actions.view-modpacks',
|
id: 'project.actions.view-modpacks',
|
||||||
defaultMessage: 'View modpacks',
|
defaultMessage: 'View modpacks',
|
||||||
},
|
},
|
||||||
startServer: {
|
|
||||||
id: 'app.project.modpack-server.start',
|
|
||||||
defaultMessage: 'Start server',
|
|
||||||
},
|
|
||||||
serverCreated: {
|
|
||||||
id: 'app.project.modpack-server.created-title',
|
|
||||||
defaultMessage: 'Server created',
|
|
||||||
},
|
|
||||||
serverCreatedDescription: {
|
|
||||||
id: 'app.project.modpack-server.created-description',
|
|
||||||
defaultMessage: '{name} is ready. Configure and start it in Multiplayer.',
|
|
||||||
},
|
|
||||||
openServer: {
|
|
||||||
id: 'app.project.modpack-server.open',
|
|
||||||
defaultMessage: 'Open server',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
||||||
@ -642,15 +603,6 @@ const favoriteSaved = computed(() =>
|
|||||||
data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false,
|
data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false,
|
||||||
)
|
)
|
||||||
|
|
||||||
const serverCapableModpack = computed(
|
|
||||||
() => data.value?.project_type === 'modpack' && data.value.server_side !== 'unsupported',
|
|
||||||
)
|
|
||||||
|
|
||||||
async function openModpackServerFlow() {
|
|
||||||
// 服务器创建弹窗(CreateModpackServerModal)已在上游移除,此流程暂不可用。
|
|
||||||
// 如需恢复:重新实现该组件,并在此处调用其 show() 方法。
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!data.value || !favoriteSupported.value || favoritePending.value) return
|
if (!data.value || !favoriteSupported.value || favoritePending.value) return
|
||||||
await contentFavorites
|
await contentFavorites
|
||||||
|
|||||||
@ -30,16 +30,6 @@
|
|||||||
<CheckIcon v-else />
|
<CheckIcon v-else />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<!-- 开服功能暂有问题,隐藏该按钮
|
|
||||||
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.startServer)"
|
|
||||||
@click.stop="() => startServer(version)"
|
|
||||||
>
|
|
||||||
<ServerIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
-->
|
|
||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
v-if="false"
|
v-if="false"
|
||||||
@ -87,7 +77,6 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
ServerIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@ -98,7 +87,7 @@ import {
|
|||||||
ProjectPageVersions,
|
ProjectPageVersions,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { SwapIcon } from '@/assets/icons/index.js'
|
import { SwapIcon } from '@/assets/icons/index.js'
|
||||||
@ -117,10 +106,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.project.versions.add-to-another-instance',
|
id: 'app.project.versions.add-to-another-instance',
|
||||||
defaultMessage: 'Add to another instance',
|
defaultMessage: 'Add to another instance',
|
||||||
},
|
},
|
||||||
startServer: {
|
|
||||||
id: 'app.project.versions.start-server',
|
|
||||||
defaultMessage: 'Create server',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -152,16 +137,8 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
startServer: {
|
|
||||||
type: Function,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverCapable = computed(
|
|
||||||
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
|
|
||||||
)
|
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import type { Router } from 'vue-router'
|
|||||||
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
|
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
|
||||||
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
|
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
|
||||||
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
|
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
get_organization,
|
get_organization,
|
||||||
get_project,
|
get_project,
|
||||||
@ -868,12 +867,6 @@ export function createContentInstall(opts: {
|
|||||||
})
|
})
|
||||||
removeInstallingItems(createdInstanceId, [project.id])
|
removeInstallingItems(createdInstanceId, [project.id])
|
||||||
markInstanceContentChanged(createdInstanceId)
|
markInstanceContentChanged(createdInstanceId)
|
||||||
trackEvent('PackInstall', {
|
|
||||||
id: project.id,
|
|
||||||
version_id: version.id,
|
|
||||||
title: project.title,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
callback(version.id, [project.id])
|
callback(version.id, [project.id])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
|
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
|
||||||
@ -997,7 +990,6 @@ export function createContentInstall(opts: {
|
|||||||
if (sessionId !== currentSessionId) return
|
if (sessionId !== currentSessionId) return
|
||||||
contentInstallModalOpen = true
|
contentInstallModalOpen = true
|
||||||
modalRef?.show()
|
modalRef?.show()
|
||||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_game_versions()
|
get_game_versions()
|
||||||
@ -1068,7 +1060,6 @@ export function createContentInstall(opts: {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
contentInstallModalOpen = true
|
contentInstallModalOpen = true
|
||||||
modalRef?.show()
|
modalRef?.show()
|
||||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
|
||||||
return sessionId
|
return sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1688,15 +1679,6 @@ export function createContentInstall(opts: {
|
|||||||
if (storeInstance) storeInstance.installing = true
|
if (storeInstance) storeInstance.installing = true
|
||||||
try {
|
try {
|
||||||
await queueCurrentCurseForgeWorld(selectedInstance)
|
await queueCurrentCurseForgeWorld(selectedInstance)
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: selectedInstance.loader,
|
|
||||||
game_version: selectedInstance.game_version,
|
|
||||||
id: currentProject.id,
|
|
||||||
version_id: currentWorldFileId ?? '',
|
|
||||||
project_type: 'world',
|
|
||||||
title: currentProject.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id])
|
settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id])
|
||||||
hideContentInstallModal()
|
hideContentInstallModal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1758,15 +1740,6 @@ export function createContentInstall(opts: {
|
|||||||
{ ...request, excluded_project_ids: excludedProjectIds },
|
{ ...request, excluded_project_ids: excludedProjectIds },
|
||||||
{ title: currentProject.title, iconUrl: currentProject.icon_url },
|
{ title: currentProject.title, iconUrl: currentProject.icon_url },
|
||||||
)
|
)
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: selectedInstance.loader,
|
|
||||||
game_version: selectedInstance.game_version,
|
|
||||||
id: currentProject.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject.project_type,
|
|
||||||
title: currentProject.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(version.id, [currentProject.id])
|
settleCurrentCallback(version.id, [currentProject.id])
|
||||||
hideContentInstallModal()
|
hideContentInstallModal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1786,15 +1759,6 @@ export function createContentInstall(opts: {
|
|||||||
settleCurrentCallback()
|
settleCurrentCallback()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: selectedInstance.loader,
|
|
||||||
game_version: selectedInstance.game_version,
|
|
||||||
id: currentProject.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject.project_type,
|
|
||||||
title: currentProject.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(version.id, [currentProject.id])
|
settleCurrentCallback(version.id, [currentProject.id])
|
||||||
hideContentInstallModal()
|
hideContentInstallModal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1830,15 +1794,6 @@ export function createContentInstall(opts: {
|
|||||||
storeInstance.installed = primaryInstalled
|
storeInstance.installed = primaryInstalled
|
||||||
storeInstance.installing = false
|
storeInstance.installing = false
|
||||||
}
|
}
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: selectedInstance.loader,
|
|
||||||
game_version: selectedInstance.game_version,
|
|
||||||
id: currentProject!.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject!.project_type,
|
|
||||||
title: currentProject!.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds)
|
settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (storeInstance) storeInstance.installing = false
|
if (storeInstance) storeInstance.installing = false
|
||||||
@ -1878,7 +1833,6 @@ export function createContentInstall(opts: {
|
|||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
incompatibilityWarningModalRef?.show(version.id)
|
incompatibilityWarningModalRef?.show(version.id)
|
||||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
|
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
|
||||||
@ -1931,15 +1885,6 @@ export function createContentInstall(opts: {
|
|||||||
incompatibilityWarningModalRef?.hide()
|
incompatibilityWarningModalRef?.hide()
|
||||||
removeInstallingItems(instance.id, [project.id])
|
removeInstallingItems(instance.id, [project.id])
|
||||||
|
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: instance.loader ?? '',
|
|
||||||
game_version: instance.game_version ?? '',
|
|
||||||
id: project.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: project.project_type,
|
|
||||||
title: project.title,
|
|
||||||
source: 'ProjectIncompatibilityWarningModal',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIncompatibilityWarningCancel() {
|
function handleIncompatibilityWarningCancel() {
|
||||||
@ -2056,16 +2001,6 @@ export function createContentInstall(opts: {
|
|||||||
},
|
},
|
||||||
{ title: currentProject!.title, iconUrl: currentProject!.icon_url },
|
{ title: currentProject!.title, iconUrl: currentProject!.icon_url },
|
||||||
)
|
)
|
||||||
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
|
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: data.loader,
|
|
||||||
game_version: data.gameVersion,
|
|
||||||
id: currentProject!.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject!.project_type,
|
|
||||||
title: currentProject!.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(version.id, [currentProject!.id])
|
settleCurrentCallback(version.id, [currentProject!.id])
|
||||||
hideContentInstallModal()
|
hideContentInstallModal()
|
||||||
return
|
return
|
||||||
@ -2085,16 +2020,6 @@ export function createContentInstall(opts: {
|
|||||||
excludedCurseForgeProjectIds,
|
excludedCurseForgeProjectIds,
|
||||||
)
|
)
|
||||||
if (!job) return
|
if (!job) return
|
||||||
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
|
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: data.loader,
|
|
||||||
game_version: data.gameVersion,
|
|
||||||
id: currentProject!.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject!.project_type,
|
|
||||||
title: currentProject!.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
settleCurrentCallback(version.id, [currentProject!.id])
|
settleCurrentCallback(version.id, [currentProject!.id])
|
||||||
hideContentInstallModal()
|
hideContentInstallModal()
|
||||||
return
|
return
|
||||||
@ -2131,18 +2056,6 @@ export function createContentInstall(opts: {
|
|||||||
: `/instance/${encodeURIComponent(id)}`,
|
: `/instance/${encodeURIComponent(id)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
trackEvent('InstanceCreate', {
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: data.loader,
|
|
||||||
game_version: data.gameVersion,
|
|
||||||
id: currentProject!.id,
|
|
||||||
version_id: version.id,
|
|
||||||
project_type: currentProject!.project_type,
|
|
||||||
title: currentProject!.title,
|
|
||||||
source: 'ProjectInstallModal',
|
|
||||||
})
|
|
||||||
|
|
||||||
settleCurrentCallback(version.id, installedProjectIds)
|
settleCurrentCallback(version.id, installedProjectIds)
|
||||||
modalRef?.hide()
|
modalRef?.hide()
|
||||||
@ -2282,15 +2195,6 @@ export function createContentInstall(opts: {
|
|||||||
{ ...installRequest, excluded_project_ids: excludedProjectIds },
|
{ ...installRequest, excluded_project_ids: excludedProjectIds },
|
||||||
{ title: project.title, iconUrl: project.icon_url },
|
{ title: project.title, iconUrl: project.icon_url },
|
||||||
)
|
)
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
id: project.id,
|
|
||||||
project_type: project.project_type,
|
|
||||||
version_id: version.id,
|
|
||||||
title: project.title,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
callback(version.id, [project.id])
|
callback(version.id, [project.id])
|
||||||
} else {
|
} else {
|
||||||
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
|
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
|
||||||
@ -2402,15 +2306,6 @@ export function createContentInstall(opts: {
|
|||||||
if (isVersionCompatible(version, project, instance)) {
|
if (isVersionCompatible(version, project, instance)) {
|
||||||
const job = await queueCurrentCurseForgeVersion(instance, project, version)
|
const job = await queueCurrentCurseForgeVersion(instance, project, version)
|
||||||
if (!job) return
|
if (!job) return
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
id: project.id,
|
|
||||||
project_type: project.project_type,
|
|
||||||
version_id: version.id,
|
|
||||||
title: project.title,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
callback(version.id, [project.id])
|
callback(version.id, [project.id])
|
||||||
} else {
|
} else {
|
||||||
await showIncompatibilityWarning(instance, project, versions, version, callback)
|
await showIncompatibilityWarning(instance, project, versions, version, callback)
|
||||||
@ -2457,7 +2352,6 @@ export function createContentInstall(opts: {
|
|||||||
if (sessionId !== currentSessionId) return
|
if (sessionId !== currentSessionId) return
|
||||||
contentInstallModalOpen = true
|
contentInstallModalOpen = true
|
||||||
modalRef?.show()
|
modalRef?.show()
|
||||||
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const candidates = (await list()).filter(
|
const candidates = (await list()).filter(
|
||||||
@ -2537,15 +2431,6 @@ export function createContentInstall(opts: {
|
|||||||
const instance = await get(instanceId)
|
const instance = await get(instanceId)
|
||||||
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
|
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
|
||||||
await queueCurrentCurseForgeWorld(instance)
|
await queueCurrentCurseForgeWorld(instance)
|
||||||
trackEvent('ProjectInstall', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
id: currentProject!.id,
|
|
||||||
version_id: file.id.toString(),
|
|
||||||
project_type: 'world',
|
|
||||||
title: currentProject!.title,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
callback(file.id.toString(), [currentProject!.id])
|
callback(file.id.toString(), [currentProject!.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2700,12 +2585,6 @@ export function createContentInstall(opts: {
|
|||||||
if (instanceId) {
|
if (instanceId) {
|
||||||
createInstanceCallback(instanceId)
|
createInstanceCallback(instanceId)
|
||||||
}
|
}
|
||||||
trackEvent('PackInstall', {
|
|
||||||
id: project.id,
|
|
||||||
version_id: versionId,
|
|
||||||
title: project.title,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
callback(versionId)
|
callback(versionId)
|
||||||
},
|
},
|
||||||
handleModpackInstallCancel() {
|
handleModpackInstallCancel() {
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { createContext } from '@modrinth/ui'
|
|||||||
import { type Ref, ref } from 'vue'
|
import { type Ref, ref } from 'vue'
|
||||||
import type { Router } from 'vue-router'
|
import type { Router } from 'vue-router'
|
||||||
|
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
|
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
|
||||||
import {
|
import {
|
||||||
install_create_instance,
|
install_create_instance,
|
||||||
@ -163,11 +162,6 @@ export function createServerInstall(opts: {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await joinServer(project.id, serverAddress)
|
await joinServer(project.id, serverAddress)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: project.loader,
|
|
||||||
game_version: project.game_version,
|
|
||||||
source: 'ServerProject',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleSevereError(err, { instanceId: project.id })
|
handleSevereError(err, { instanceId: project.id })
|
||||||
}
|
}
|
||||||
@ -198,11 +192,6 @@ export function createServerInstall(opts: {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
if (serverAddress) await start_join_server(instance.id, serverAddress)
|
if (serverAddress) await start_join_server(instance.id, serverAddress)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'ServerProject',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleSevereError(err, { instanceId: instance.id })
|
handleSevereError(err, { instanceId: instance.id })
|
||||||
}
|
}
|
||||||
@ -348,11 +337,6 @@ export function createServerInstall(opts: {
|
|||||||
// Join server
|
// Join server
|
||||||
try {
|
try {
|
||||||
await joinServer(instance.id, serverAddress)
|
await joinServer(instance.id, serverAddress)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'ServerProject',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleSevereError(err, { instanceId: instance.id })
|
handleSevereError(err, { instanceId: instance.id })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||||
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
|
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
|
||||||
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
|
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
|
||||||
import { install_job_listener } from '@/helpers/events.js'
|
import { install_job_listener } from '@/helpers/events.js'
|
||||||
@ -143,7 +142,6 @@ export function setupCreationModal(
|
|||||||
},
|
},
|
||||||
{ name },
|
{ name },
|
||||||
).catch(handleError)
|
).catch(handleError)
|
||||||
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(config: CreationFlowContextValue) {
|
async function handleCreate(config: CreationFlowContextValue) {
|
||||||
@ -222,7 +220,6 @@ export function setupCreationModal(
|
|||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,7 +235,6 @@ export function setupCreationModal(
|
|||||||
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
|
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
|
||||||
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
|
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
|
||||||
await installModpackFromPath(config.modpackFilePath.value, fileName)
|
await installModpackFromPath(config.modpackFilePath.value, fileName)
|
||||||
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -281,9 +277,6 @@ export function setupCreationModal(
|
|||||||
gameDirOverride,
|
gameDirOverride,
|
||||||
}).catch(handleError)
|
}).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceCreate', {
|
|
||||||
source: 'CreationModal',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -509,10 +509,6 @@ fn main() {
|
|||||||
.commands(&[
|
.commands(&[
|
||||||
"settings_get",
|
"settings_get",
|
||||||
"settings_set",
|
"settings_set",
|
||||||
"privacy_get",
|
|
||||||
"privacy_set",
|
|
||||||
"telemetry_set",
|
|
||||||
"discord_rpc_set",
|
|
||||||
"download_engine_set",
|
"download_engine_set",
|
||||||
"cancel_directory_change",
|
"cancel_directory_change",
|
||||||
"proxy_get",
|
"proxy_get",
|
||||||
|
|||||||
@ -6,7 +6,6 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use theseus::DownloadReason;
|
|
||||||
use theseus::data::{
|
use theseus::data::{
|
||||||
AppliedContentSetPatch, ContentItem, Dependency,
|
AppliedContentSetPatch, ContentItem, Dependency,
|
||||||
EditInstance as CoreEditInstance, InstanceInstallCandidate,
|
EditInstance as CoreEditInstance, InstanceInstallCandidate,
|
||||||
@ -1116,16 +1115,11 @@ pub async fn instance_update_project(
|
|||||||
pub async fn instance_add_project_from_version(
|
pub async fn instance_add_project_from_version(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
version_id: &str,
|
version_id: &str,
|
||||||
reason: DownloadReason,
|
|
||||||
dependent_on_version_id: Option<String>,
|
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
Ok(theseus::instance::add_project_from_version(
|
Ok(
|
||||||
instance_id,
|
theseus::instance::add_project_from_version(instance_id, version_id)
|
||||||
version_id,
|
.await?,
|
||||||
reason,
|
|
||||||
dependent_on_version_id,
|
|
||||||
)
|
)
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@ -25,7 +25,6 @@ pub mod shortcuts;
|
|||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod system_accent;
|
pub mod system_accent;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod telemetry;
|
|
||||||
pub mod terracotta;
|
pub mod terracotta;
|
||||||
pub mod translation;
|
pub mod translation;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|||||||
@ -8,10 +8,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
settings_get,
|
settings_get,
|
||||||
settings_set,
|
settings_set,
|
||||||
privacy_get,
|
|
||||||
privacy_set,
|
|
||||||
telemetry_set,
|
|
||||||
discord_rpc_set,
|
|
||||||
download_engine_set,
|
download_engine_set,
|
||||||
cancel_directory_change,
|
cancel_directory_change,
|
||||||
proxy_get,
|
proxy_get,
|
||||||
@ -41,26 +37,6 @@ pub async fn settings_set(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn privacy_get() -> Result<PrivacySettings> {
|
|
||||||
Ok(settings::get_privacy().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn privacy_set(privacy: PrivacySettings) -> Result<PrivacySettings> {
|
|
||||||
Ok(settings::set_privacy(privacy).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn telemetry_set(enabled: bool) -> Result<PrivacySettings> {
|
|
||||||
Ok(settings::set_telemetry(enabled).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn discord_rpc_set(enabled: bool) -> Result<PrivacySettings> {
|
|
||||||
Ok(settings::set_discord_rpc(enabled).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn download_engine_set(
|
pub async fn download_engine_set(
|
||||||
engine: settings::DownloadEngine,
|
engine: settings::DownloadEngine,
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|
||||||
tauri::plugin::Builder::new("telemetry")
|
|
||||||
.invoke_handler(tauri::generate_handler![notify_online,])
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn notify_online() {
|
|
||||||
theseus::telemetry::notify_online();
|
|
||||||
}
|
|
||||||
@ -389,16 +389,6 @@ async fn backup_app_db_for_update(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn set_discord_activity(activity: String) -> api::Result<()> {
|
|
||||||
let state = State::get().await?;
|
|
||||||
state
|
|
||||||
.discord_rpc
|
|
||||||
.set_launcher_activity(&activity, true)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should be call once Vue has mounted the app
|
// Should be call once Vue has mounted the app
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -698,6 +688,13 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
builder = builder
|
builder = builder
|
||||||
|
.plugin(
|
||||||
|
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
|
||||||
|
.js_init_script_on_all_frames(include_str!(
|
||||||
|
"skin_site_bridge.js"
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
if let Some(payload) = args.get(1) {
|
if let Some(payload) = args.get(1) {
|
||||||
tracing::info!("Handling deep link from arg {payload}");
|
tracing::info!("Handling deep link from arg {payload}");
|
||||||
@ -823,7 +820,6 @@ fn main() {
|
|||||||
.plugin(api::schematic_preview::init())
|
.plugin(api::schematic_preview::init())
|
||||||
.plugin(api::shortcuts::init())
|
.plugin(api::shortcuts::init())
|
||||||
.plugin(api::tags::init())
|
.plugin(api::tags::init())
|
||||||
.plugin(api::telemetry::init())
|
|
||||||
.plugin(api::translation::init())
|
.plugin(api::translation::init())
|
||||||
.plugin(api::utils::init())
|
.plugin(api::utils::init())
|
||||||
.plugin(api::cache::init())
|
.plugin(api::cache::init())
|
||||||
@ -851,7 +847,6 @@ fn main() {
|
|||||||
copy_database_between_channels,
|
copy_database_between_channels,
|
||||||
beta_database_exists,
|
beta_database_exists,
|
||||||
backup_app_db_for_update,
|
backup_app_db_for_update,
|
||||||
set_discord_activity,
|
|
||||||
is_dev,
|
is_dev,
|
||||||
portable::is_portable_mode,
|
portable::is_portable_mode,
|
||||||
are_updates_enabled,
|
are_updates_enabled,
|
||||||
|
|||||||
98
apps/app/src/skin_site_bridge.js
Normal file
98
apps/app/src/skin_site_bridge.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Runs inside the embedded skin site only. The JWT never leaves that origin.
|
||||||
|
(() => {
|
||||||
|
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
|
||||||
|
|
||||||
|
const launcherOrigins = new Set([
|
||||||
|
'http://localhost:5201',
|
||||||
|
'http://tauri.localhost',
|
||||||
|
'https://tauri.localhost',
|
||||||
|
'tauri://localhost',
|
||||||
|
])
|
||||||
|
let parentOrigin
|
||||||
|
let lastToken
|
||||||
|
let lastCheck = 0
|
||||||
|
let generation = 0
|
||||||
|
let pending
|
||||||
|
let snapshot = { status: 'checking', user: null }
|
||||||
|
|
||||||
|
function publish(status, user = null) {
|
||||||
|
snapshot = { status, user }
|
||||||
|
if (parentOrigin) {
|
||||||
|
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSession(force = false) {
|
||||||
|
if (!parentOrigin) return
|
||||||
|
let token
|
||||||
|
try {
|
||||||
|
token = localStorage.getItem('loginToken') || ''
|
||||||
|
} catch {
|
||||||
|
publish('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const changed = token !== lastToken
|
||||||
|
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
|
||||||
|
const revision = ++generation
|
||||||
|
pending?.abort()
|
||||||
|
pending = undefined
|
||||||
|
lastToken = token
|
||||||
|
lastCheck = Date.now()
|
||||||
|
if (!token) {
|
||||||
|
publish('signed-out')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (changed) publish('checking')
|
||||||
|
const controller = new AbortController()
|
||||||
|
pending = controller
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/starlight/user', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const body = response.ok ? await response.json() : null
|
||||||
|
// A logout or account switch must win over an older in-flight response.
|
||||||
|
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
|
||||||
|
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
|
||||||
|
publish('signed-out')
|
||||||
|
} else if (
|
||||||
|
response.ok && typeof body?.payload?.uuid === 'string' &&
|
||||||
|
typeof body.payload.username === 'string' && body.payload.username.length > 0
|
||||||
|
) {
|
||||||
|
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
|
||||||
|
} else {
|
||||||
|
publish('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (revision === generation) publish('error')
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (revision === generation) pending = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
|
||||||
|
if (event.data?.type !== 'starlight-skin-session-connect') return
|
||||||
|
parentOrigin = event.origin
|
||||||
|
publish(snapshot.status, snapshot.user)
|
||||||
|
void checkSession(true)
|
||||||
|
})
|
||||||
|
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
|
||||||
|
window.addEventListener('storage', () => void checkSession())
|
||||||
|
let timer = setInterval(() => void checkSession(), 1000)
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
clearInterval(timer)
|
||||||
|
++generation
|
||||||
|
pending?.abort()
|
||||||
|
pending = undefined
|
||||||
|
})
|
||||||
|
window.addEventListener('pageshow', (event) => {
|
||||||
|
if (!event.persisted) return
|
||||||
|
timer = setInterval(() => void checkSession(), 1000)
|
||||||
|
void checkSession(true)
|
||||||
|
})
|
||||||
|
})()
|
||||||
67
apps/app/src/skin_site_bridge.test.cjs
Normal file
67
apps/app/src/skin_site_bridge.test.cjs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const test = require('node:test')
|
||||||
|
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
|
||||||
|
|
||||||
|
function harness(origin = 'https://skin.starlight.cool') {
|
||||||
|
let token = null
|
||||||
|
const listeners = {}, messages = [], requests = []
|
||||||
|
let tick
|
||||||
|
let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
|
||||||
|
const parent = { postMessage(data, target) { messages.push({ data, target }) } }
|
||||||
|
const context = {
|
||||||
|
location: { origin },
|
||||||
|
window: { parent, addEventListener(type, callback) { listeners[type] = callback } },
|
||||||
|
localStorage: { getItem() { return token } },
|
||||||
|
fetch(...args) { requests.push(args); return result(...args) },
|
||||||
|
AbortController, Date, setTimeout, clearTimeout,
|
||||||
|
setInterval(callback) { tick = callback; return 1 }, clearInterval() {},
|
||||||
|
}
|
||||||
|
vm.runInNewContext(source, context)
|
||||||
|
return {
|
||||||
|
messages, requests, listeners, parent,
|
||||||
|
token(value) { token = value }, result(value) { result = value },
|
||||||
|
async tick() { tick?.(); await new Promise(setImmediate) },
|
||||||
|
async connect(origin = 'http://localhost:5201', source = parent) {
|
||||||
|
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
|
||||||
|
assert.equal(harness('https://evil.example').listeners.message, undefined)
|
||||||
|
const h = harness(); h.token('test-token')
|
||||||
|
await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {})
|
||||||
|
assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0)
|
||||||
|
await h.connect()
|
||||||
|
assert.equal(h.requests[0][0], '/starlight/user')
|
||||||
|
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
|
||||||
|
assert.equal(h.requests[0][1].redirect, 'error')
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-in')
|
||||||
|
assert.ok(h.messages.every(m => m.target === 'http://localhost:5201'))
|
||||||
|
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout, invalid token, network error and account switching replace old state', async () => {
|
||||||
|
const h = harness(); await h.connect()
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One')
|
||||||
|
h.result(async () => ({ ok: false, status: 401 }))
|
||||||
|
h.token('expired'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
h.result(async () => { throw Error('offline') })
|
||||||
|
h.token('network-error'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'error')
|
||||||
|
h.token(null); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a delayed response cannot restore the user after logout', async () => {
|
||||||
|
const h = harness(); let finish
|
||||||
|
h.result(() => new Promise(resolve => { finish = resolve }))
|
||||||
|
h.token('first'); await h.connect()
|
||||||
|
h.token(null); await h.tick()
|
||||||
|
finish({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.equal(h.messages.at(-1).data.status, 'signed-out')
|
||||||
|
assert.ok(!h.messages.some(m => m.data.status === 'signed-in'))
|
||||||
|
})
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# Fictional local values only. Never commit a real Access token or audience.
|
|
||||||
TELEMETRY_ADMIN_MOCK_AUTH=true
|
|
||||||
TELEMETRY_ADMIN_MOCK_SCENARIO=normal
|
|
||||||
NUXT_ACCESS_TEAM_DOMAIN=example
|
|
||||||
NUXT_ACCESS_AUDIENCE=fixture-audience
|
|
||||||
|
|
||||||
# Cloudflare GraphQL Analytics API (system page account usage check, local dev only)
|
|
||||||
# CLOUDFLARE_ACCOUNT_ID=
|
|
||||||
# CLOUDFLARE_ANALYTICS_TOKEN=
|
|
||||||
2
apps/telemetry-dashboard/.gitignore
vendored
2
apps/telemetry-dashboard/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
.vercel
|
|
||||||
.env*
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
.nuxt/
|
|
||||||
.output/
|
|
||||||
.wrangler/
|
|
||||||
node_modules/
|
|
||||||
coverage/
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
# Starlight Telemetry Dashboard
|
|
||||||
|
|
||||||
Read-only administration UI for the existing `axolotl-telemetry` D1 database and
|
|
||||||
`axolotl-telemetry-errors` R2 bucket. It does not share routes or code with the public telemetry
|
|
||||||
collector.
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
Mock authentication is accepted only when `NODE_ENV=development` and
|
|
||||||
`TELEMETRY_ADMIN_MOCK_AUTH=true` are both present.
|
|
||||||
|
|
||||||
Use the production D1 and R2 resources through Wrangler remote bindings while keeping authentication
|
|
||||||
local to the development server:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
|
|
||||||
$env:TELEMETRY_ADMIN_REMOTE_BINDINGS='true'
|
|
||||||
pnpm --filter @axolotl/telemetry-dashboard dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Remote mode is read-only at the application layer: the Admin API issues only `SELECT` statements
|
|
||||||
and a single registered-object R2 `GET`. It never exposes Cloudflare credentials to the browser.
|
|
||||||
|
|
||||||
For deterministic fixture scenarios, leave `TELEMETRY_ADMIN_REMOTE_BINDINGS` unset:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
|
|
||||||
$env:TELEMETRY_ADMIN_MOCK_SCENARIO='normal'
|
|
||||||
pnpm --filter @axolotl/telemetry-dashboard dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported deterministic scenarios are `normal`, `empty`, `api-error`, `no-sample`,
|
|
||||||
`budget-reached`, `unconfigured-auth`, and `forbidden`. All mock values are synthetic fixtures.
|
|
||||||
|
|
||||||
## Production deployment (Cloudflare Workers)
|
|
||||||
|
|
||||||
The dashboard runs on the `axolotl-telemetry-dashboard` Worker with the custom domain
|
|
||||||
`admin.axlmc.org`. It uses the native D1 and R2 bindings declared in `wrangler.toml`; no data
|
|
||||||
credentials are sent to the browser.
|
|
||||||
|
|
||||||
Do not deploy the dashboard until every item below is complete:
|
|
||||||
|
|
||||||
1. Create a GitHub OAuth App controlled by the `Axolotl-Launcher` organization. Use
|
|
||||||
`https://admin.axlmc.org` as its homepage and the callback URL shown by Cloudflare Zero Trust,
|
|
||||||
normally `https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
|
|
||||||
2. Add GitHub as a Cloudflare Zero Trust identity provider using that Client ID and Client Secret.
|
|
||||||
Keep the Client Secret only in GitHub and Cloudflare.
|
|
||||||
3. Create a self-hosted Access application for `admin.axlmc.org` with an 8-hour session duration.
|
|
||||||
4. Create one Allow policy with `Include -> GitHub Organization -> Axolotl-Launcher`. Do not add a
|
|
||||||
bypass policy or a broader include rule.
|
|
||||||
5. Keep the Worker custom domain `admin.axlmc.org` bound to `axolotl-telemetry-dashboard`. The DNS
|
|
||||||
records for `axlmc.org` stay proxied through Cloudflare.
|
|
||||||
|
|
||||||
Build and deploy from `apps/telemetry-dashboard`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pnpm --filter @axolotl/telemetry-dashboard build
|
|
||||||
pnpm --filter @axolotl/telemetry-dashboard exec wrangler deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
`nuxi build` uses Nitro's `cloudflare-module` preset by default and emits
|
|
||||||
`.output/server/index.mjs` plus `.output/public`, exactly what `wrangler.toml` references.
|
|
||||||
|
|
||||||
### Automatic deployment
|
|
||||||
|
|
||||||
`.github/workflows/deploy-telemetry-dashboard.yml` deploys on every push to `main` that touches
|
|
||||||
`apps/telemetry-dashboard/**`, and can also be triggered manually. It requires one repository
|
|
||||||
secret:
|
|
||||||
|
|
||||||
- `CLOUDFLARE_API_TOKEN`: an API token with Workers Scripts Edit and Workers Routes Edit
|
|
||||||
permission for the account that owns `axolotl-telemetry-dashboard`.
|
|
||||||
|
|
||||||
Do not add `[skip ci]` to commits that should reach production: GitHub Actions skips all workflows
|
|
||||||
for such commits, including this deployment.
|
|
||||||
|
|
||||||
### Cloudflare account usage check
|
|
||||||
|
|
||||||
The system page reports account-wide Workers, D1, and R2 usage through the GraphQL Analytics API.
|
|
||||||
Enable it once with two Worker secrets; secrets survive CI deploys because they live on the Worker,
|
|
||||||
not in the repository:
|
|
||||||
|
|
||||||
1. Create an API token at `dash.cloudflare.com` → My Profile → API Tokens with the
|
|
||||||
`Account Analytics: Read` permission for the account that owns the telemetry resources.
|
|
||||||
2. From `apps/telemetry-dashboard`, run
|
|
||||||
`pnpm exec wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN` and paste that token.
|
|
||||||
3. Run `pnpm exec wrangler secret put CLOUDFLARE_ACCOUNT_ID` and paste the 32-character account ID.
|
|
||||||
|
|
||||||
The check stays fail-closed: without both values the row reports 尚未配置, and any dataset failure
|
|
||||||
only degrades the row. The token is used server-side for three read-only GraphQL queries per system
|
|
||||||
page load (Workers invocations 24h, D1 rows today, R2 operations 30d) and is never sent to the
|
|
||||||
browser.
|
|
||||||
|
|
||||||
### Unused Vercel target
|
|
||||||
|
|
||||||
An earlier Vercel project (`axolotl-telemetry-dashboard`) exists, but its `admin.axlmc.org` domain
|
|
||||||
was never pointed at Vercel and traffic is served by the Cloudflare Worker. The Nitro `vercel`
|
|
||||||
preset and `server/utils/vercel-data-source.ts` remain as an offline fallback and are not part of
|
|
||||||
the production path. Do not enable both targets for the same hostname.
|
|
||||||
|
|
||||||
Verify an unauthenticated request is intercepted by Cloudflare Access, then verify
|
|
||||||
`/api/admin/session` reports `dataSource: production` after authentication.
|
|
||||||
|
|
||||||
The server fails closed when Access or the remote data source configuration is absent. A production
|
|
||||||
build ignores the mock provider even if a mock environment variable is present. All Admin API D1
|
|
||||||
operations are restricted to `SELECT`/`WITH` queries, and R2 reads require an object key previously
|
|
||||||
registered in D1.
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtRouteAnnouncer />
|
|
||||||
<NuxtLayout>
|
|
||||||
<NuxtPage />
|
|
||||||
</NuxtLayout>
|
|
||||||
</template>
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 210 18% 97%;
|
|
||||||
--foreground: 220 20% 11%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 220 18% 12%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 220 18% 12%;
|
|
||||||
--primary: 164 64% 27%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
--secondary: 210 16% 93%;
|
|
||||||
--secondary-foreground: 220 16% 18%;
|
|
||||||
--muted: 210 16% 94%;
|
|
||||||
--muted-foreground: 215 12% 43%;
|
|
||||||
--accent: 38 92% 52%;
|
|
||||||
--accent-foreground: 30 30% 12%;
|
|
||||||
--destructive: 0 72% 48%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
--border: 214 16% 86%;
|
|
||||||
--input: 214 16% 78%;
|
|
||||||
--ring: 164 64% 27%;
|
|
||||||
--surface-1: 210 18% 97%;
|
|
||||||
--surface-2: 0 0% 100%;
|
|
||||||
--surface-3: 210 17% 94%;
|
|
||||||
--surface-4: 214 16% 86%;
|
|
||||||
--surface-5: 214 15% 72%;
|
|
||||||
--chart-green: #17845f;
|
|
||||||
--chart-gold: #d19213;
|
|
||||||
--chart-coral: #d85d4a;
|
|
||||||
--chart-cyan: #278ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 220 16% 8%;
|
|
||||||
--foreground: 210 16% 92%;
|
|
||||||
--card: 220 15% 12%;
|
|
||||||
--card-foreground: 210 16% 92%;
|
|
||||||
--popover: 220 16% 12%;
|
|
||||||
--popover-foreground: 210 16% 92%;
|
|
||||||
--primary: 160 54% 48%;
|
|
||||||
--primary-foreground: 220 18% 8%;
|
|
||||||
--secondary: 218 14% 17%;
|
|
||||||
--secondary-foreground: 210 16% 90%;
|
|
||||||
--muted: 218 14% 16%;
|
|
||||||
--muted-foreground: 215 12% 64%;
|
|
||||||
--accent: 40 88% 56%;
|
|
||||||
--accent-foreground: 30 32% 10%;
|
|
||||||
--destructive: 0 62% 51%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
--border: 218 13% 23%;
|
|
||||||
--input: 218 13% 27%;
|
|
||||||
--ring: 160 54% 48%;
|
|
||||||
--surface-1: 220 16% 8%;
|
|
||||||
--surface-2: 220 15% 11%;
|
|
||||||
--surface-3: 218 14% 15%;
|
|
||||||
--surface-4: 218 13% 23%;
|
|
||||||
--surface-5: 216 12% 34%;
|
|
||||||
--chart-green: #40c995;
|
|
||||||
--chart-gold: #e6ad3e;
|
|
||||||
--chart-coral: #ef7b68;
|
|
||||||
--chart-cyan: #52b6d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#__nuxt {
|
|
||||||
@apply min-h-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply m-0 overflow-x-hidden bg-surface-1 font-sans text-foreground antialiased;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.tabular-nums {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-page-enter-active,
|
|
||||||
.dashboard-page-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-page-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-page-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-overlay[data-state='open'] {
|
|
||||||
animation: overlay-in 180ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-panel[data-state='open'] {
|
|
||||||
animation: sheet-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes overlay-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sheet-in {
|
|
||||||
from {
|
|
||||||
transform: translateX(20px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.dashboard-page-enter-active,
|
|
||||||
.dashboard-page-leave-active,
|
|
||||||
.sheet-overlay[data-state='open'],
|
|
||||||
.sheet-panel[data-state='open'] {
|
|
||||||
animation: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.unovis-xy-container text {
|
|
||||||
fill: hsl(var(--muted-foreground));
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unovis-xy-container .axis line,
|
|
||||||
.unovis-xy-container .axis path {
|
|
||||||
stroke: hsl(var(--surface-4));
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://shadcn-vue.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"typescript": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "assets/styles/tailwind.css",
|
|
||||||
"baseColor": "zinc",
|
|
||||||
"cssVariables": true
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "~/components",
|
|
||||||
"utils": "~/lib/utils",
|
|
||||||
"ui": "~/components/ui"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Ban, LogIn, RefreshCw, ServerOff } from 'lucide-vue-next'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import Button from '~/components/ui/Button.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
kind: 'error' | 'unauthenticated' | 'forbidden'
|
|
||||||
compact?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ retry: [] }>()
|
|
||||||
|
|
||||||
const content = computed(() => {
|
|
||||||
if (props.kind === 'unauthenticated') {
|
|
||||||
return {
|
|
||||||
icon: LogIn,
|
|
||||||
title: '需要登录',
|
|
||||||
detail: '请使用 Axolotl-Launcher 组织成员账号通过 Cloudflare Access 登录。',
|
|
||||||
action: '前往登录',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (props.kind === 'forbidden') {
|
|
||||||
return {
|
|
||||||
icon: Ban,
|
|
||||||
title: '无权访问',
|
|
||||||
detail: '当前 GitHub 身份不符合 Cloudflare Access 组织策略。',
|
|
||||||
action: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
icon: ServerOff,
|
|
||||||
title: '遥测数据暂不可用',
|
|
||||||
detail: '只读管理 API 未返回可用结果,请稍后重试。',
|
|
||||||
action: '重新加载',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="compact ? 'py-10' : 'min-h-[60vh]'"
|
|
||||||
class="flex flex-col items-center justify-center px-4 text-center"
|
|
||||||
:data-state="kind"
|
|
||||||
>
|
|
||||||
<div class="flex size-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<component :is="content.icon" class="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h2 class="mt-4 text-base font-semibold">{{ content.title }}</h2>
|
|
||||||
<p class="mt-1 max-w-md text-sm text-muted-foreground">{{ content.detail }}</p>
|
|
||||||
<Button v-if="content.action" class="mt-4" variant="outline" @click="emit('retry')">
|
|
||||||
<RefreshCw v-if="kind === 'error'" class="size-4" />
|
|
||||||
<LogIn v-else class="size-4" />
|
|
||||||
{{ content.action }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Component } from 'vue'
|
|
||||||
|
|
||||||
import Card from '~/components/ui/Card.vue'
|
|
||||||
import { formatNumber } from '~/utils/format'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
value: number
|
|
||||||
detail: string
|
|
||||||
icon: Component
|
|
||||||
tone?: 'green' | 'cyan' | 'gold' | 'coral' | 'neutral'
|
|
||||||
}>(),
|
|
||||||
{ tone: 'neutral' },
|
|
||||||
)
|
|
||||||
|
|
||||||
const tones = {
|
|
||||||
green: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
|
|
||||||
cyan: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-300',
|
|
||||||
gold: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
|
|
||||||
coral: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
|
|
||||||
neutral: 'bg-surface-3 text-muted-foreground',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card class="min-w-0 overflow-hidden">
|
|
||||||
<div class="flex items-start justify-between gap-3 p-4 pb-3">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="truncate text-xs font-medium text-muted-foreground">{{ title }}</p>
|
|
||||||
<p class="mt-2 text-[28px] font-semibold tabular-nums leading-8">
|
|
||||||
{{ formatNumber(value) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-md"
|
|
||||||
:class="tones[props.tone]"
|
|
||||||
>
|
|
||||||
<component :is="icon" class="size-[18px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="truncate border-t border-surface-4 bg-surface-3/45 px-4 py-2 text-[11px] text-muted-foreground"
|
|
||||||
:title="detail"
|
|
||||||
>
|
|
||||||
{{ detail }}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{ title: string; description: string }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="mb-6 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="text-[22px] font-semibold leading-8">{{ title }}</h1>
|
|
||||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { DistributionItemDto } from '~/shared/types/telemetry'
|
|
||||||
import { formatNumber } from '~/utils/format'
|
|
||||||
|
|
||||||
const props = defineProps<{ items: DistributionItemDto[]; color: string }>()
|
|
||||||
const maximum = computed(() => Math.max(1, ...props.items.map((item) => item.value)))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="items.length" class="h-56 space-y-3.5 overflow-y-auto pr-1">
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.label"
|
|
||||||
class="group"
|
|
||||||
:title="`${item.label}: ${formatNumber(item.value)}`"
|
|
||||||
>
|
|
||||||
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
|
|
||||||
<span class="truncate font-medium">{{ item.label }}</span>
|
|
||||||
<span class="shrink-0 tabular-nums text-muted-foreground">{{
|
|
||||||
formatNumber(item.value)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-1.5 overflow-hidden rounded-sm bg-surface-3">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-sm transition-[width] duration-300"
|
|
||||||
:style="{ width: `${(item.value / maximum) * 100}%`, backgroundColor: color }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
|
|
||||||
data-state="empty"
|
|
||||||
>
|
|
||||||
当前 UTC 时间范围内暂无数据
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
import type { DailyPointDto } from '~/shared/types/telemetry'
|
|
||||||
import { formatNumber, formatUtcDay } from '~/utils/format'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: DailyPointDto[]
|
|
||||||
series: Array<{
|
|
||||||
key: 'activeInstallations' | 'newInstallations'
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
}>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const hovered = ref<number | null>(null)
|
|
||||||
const available = computed(() =>
|
|
||||||
props.data.some((point) => props.series.some((series) => Number(point[series.key]) > 0)),
|
|
||||||
)
|
|
||||||
const yAccessors = computed(() =>
|
|
||||||
props.series.map((series) => (point: DailyPointDto) => point[series.key]),
|
|
||||||
)
|
|
||||||
const colors = computed(() => props.series.map((series) => series.color))
|
|
||||||
|
|
||||||
function move(event: PointerEvent): void {
|
|
||||||
if (!props.data.length) return
|
|
||||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
const ratio = Math.min(1, Math.max(0, (event.clientX - box.left) / box.width))
|
|
||||||
hovered.value = Math.min(props.data.length - 1, Math.round(ratio * (props.data.length - 1)))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative h-64 min-w-0" @pointermove="move" @pointerleave="hovered = null">
|
|
||||||
<div v-if="available" class="h-56 w-full">
|
|
||||||
<VisXYContainer :data="data" :margin="{ left: 16, right: 12, top: 12, bottom: 28 }">
|
|
||||||
<VisLine
|
|
||||||
:x="(_point: DailyPointDto, index: number) => index"
|
|
||||||
:y="yAccessors"
|
|
||||||
:color="colors"
|
|
||||||
:line-width="2"
|
|
||||||
/>
|
|
||||||
<VisAxis
|
|
||||||
type="x"
|
|
||||||
:x="(_point: DailyPointDto, index: number) => index"
|
|
||||||
:tick-format="(value: number) => formatUtcDay(data[Math.round(value)]?.day || '')"
|
|
||||||
:num-ticks="4"
|
|
||||||
/>
|
|
||||||
<VisAxis type="y" :num-ticks="4" :tick-format="(value: number) => formatNumber(value)" />
|
|
||||||
</VisXYContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
|
|
||||||
data-state="empty"
|
|
||||||
>
|
|
||||||
当前 UTC 时间范围内暂无数据
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex h-8 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
|
||||||
aria-label="图表图例"
|
|
||||||
>
|
|
||||||
<span v-for="item in series" :key="item.key" class="inline-flex items-center gap-1.5">
|
|
||||||
<span class="size-2 rounded-sm" :style="{ backgroundColor: item.color }"></span>
|
|
||||||
{{ item.label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="hovered !== null && available"
|
|
||||||
class="pointer-events-none absolute right-2 top-2 z-10 min-w-40 rounded-md border bg-popover p-2 text-xs text-popover-foreground shadow-md"
|
|
||||||
role="tooltip"
|
|
||||||
>
|
|
||||||
<p class="mb-1 font-medium">{{ data[hovered].day }}(UTC)</p>
|
|
||||||
<p
|
|
||||||
v-for="item in series"
|
|
||||||
:key="item.key"
|
|
||||||
class="flex justify-between gap-4 py-0.5 text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
<span class="font-medium tabular-nums text-foreground">{{
|
|
||||||
formatNumber(data[hovered][item.key])
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CircleCheck, CircleMinus, TriangleAlert } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
import Badge from '~/components/ui/Badge.vue'
|
|
||||||
import type { ServiceCheckDto } from '~/shared/types/telemetry'
|
|
||||||
|
|
||||||
defineProps<{ name: string; check: ServiceCheckDto }>()
|
|
||||||
|
|
||||||
const icons = { available: CircleCheck, degraded: TriangleAlert, unavailable: CircleMinus }
|
|
||||||
const variants = { available: 'success', degraded: 'warning', unavailable: 'secondary' } as const
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-3 border-b border-surface-4 px-4 py-3.5 transition-colors last:border-0 hover:bg-surface-3/45"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[check.status]"
|
|
||||||
class="size-4 shrink-0"
|
|
||||||
:class="
|
|
||||||
check.status === 'available'
|
|
||||||
? 'text-emerald-600'
|
|
||||||
: check.status === 'degraded'
|
|
||||||
? 'text-amber-600'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div class="min-w-48 flex-1">
|
|
||||||
<p class="text-sm font-medium">{{ name }}</p>
|
|
||||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ check.detail }}</p>
|
|
||||||
</div>
|
|
||||||
<Badge :variant="variants[check.status]">{{ check.label }}</Badge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { AlertCircle, CircleCheck, Info } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
import { cn } from '~/lib/utils'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{ variant?: 'info' | 'success' | 'destructive'; title: string; class?: string }>(),
|
|
||||||
{ variant: 'info', class: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const icons = { info: Info, success: CircleCheck, destructive: AlertCircle }
|
|
||||||
const styles = {
|
|
||||||
info: 'border-border bg-muted/40',
|
|
||||||
success: 'border-emerald-500/30 bg-emerald-500/10',
|
|
||||||
destructive: 'border-red-500/30 bg-red-500/10',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
:class="cn('flex gap-3 rounded-lg border p-3.5 text-sm', styles[variant], props.class)"
|
|
||||||
>
|
|
||||||
<component :is="icons[variant]" class="mt-0.5 size-4 shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="font-medium">{{ title }}</p>
|
|
||||||
<div class="mt-0.5 text-muted-foreground"><slot /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { cn } from '~/lib/utils'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
|
|
||||||
class?: string
|
|
||||||
}>(),
|
|
||||||
{ variant: 'secondary', class: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
default: 'bg-primary text-primary-foreground',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground',
|
|
||||||
success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
|
||||||
warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
|
|
||||||
destructive: 'bg-red-500/15 text-red-700 dark:text-red-300',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
|
|
||||||
variants[variant],
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ButtonHTMLAttributes } from 'vue'
|
|
||||||
|
|
||||||
import { cn } from '~/lib/utils'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
variant?: 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive'
|
|
||||||
size?: 'default' | 'sm' | 'icon'
|
|
||||||
class?: ButtonHTMLAttributes['class']
|
|
||||||
}>(),
|
|
||||||
{ variant: 'default', size: 'default', class: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-95 active:brightness-90',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
|
|
||||||
ghost: 'text-foreground hover:bg-surface-3 active:bg-surface-4',
|
|
||||||
outline:
|
|
||||||
'border-surface-4 bg-surface-2 text-foreground hover:border-surface-5 hover:bg-surface-3 active:bg-surface-4',
|
|
||||||
destructive: 'bg-destructive text-destructive-foreground hover:brightness-95',
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
default: 'h-9 px-3.5',
|
|
||||||
sm: 'h-8 px-2.5 text-xs',
|
|
||||||
icon: 'size-9 shrink-0 p-0',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
:data-variant="variant"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,filter] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
variants[props.variant],
|
|
||||||
sizes[props.size],
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from 'vue'
|
|
||||||
|
|
||||||
import { cn } from '~/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'rounded-lg border border-surface-4 bg-surface-2 text-card-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { BarChart3, Search, Server } from 'lucide-vue-next'
|
|
||||||
import {
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogRoot,
|
|
||||||
DialogTitle,
|
|
||||||
} from 'reka-ui'
|
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { required: true })
|
|
||||||
const query = ref('')
|
|
||||||
const router = useRouter()
|
|
||||||
const items = [
|
|
||||||
{ label: '数据总览', path: '/', icon: BarChart3 },
|
|
||||||
{ label: '系统状态', path: '/system', icon: Server },
|
|
||||||
]
|
|
||||||
const filtered = computed(() =>
|
|
||||||
items.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function select(path: string): Promise<void> {
|
|
||||||
open.value = false
|
|
||||||
query.value = ''
|
|
||||||
await router.push(path)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogRoot v-model:open="open">
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
|
|
||||||
<DialogContent
|
|
||||||
class="fixed left-1/2 top-[18vh] z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border border-surface-5 bg-popover shadow-2xl outline-none"
|
|
||||||
>
|
|
||||||
<DialogTitle class="sr-only">快速跳转</DialogTitle>
|
|
||||||
<DialogDescription class="sr-only">搜索控制台页面</DialogDescription>
|
|
||||||
<div class="flex items-center gap-2 border-b px-3">
|
|
||||||
<Search class="size-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
v-model="query"
|
|
||||||
autofocus
|
|
||||||
placeholder="输入页面名称..."
|
|
||||||
class="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="p-1.5">
|
|
||||||
<p v-if="!filtered.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
没有匹配的页面
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
v-for="item in filtered"
|
|
||||||
:key="item.path"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-surface-3 focus:bg-surface-3 focus:outline-none"
|
|
||||||
@click="select(item.path)"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" class="size-4 text-muted-foreground" />
|
|
||||||
{{ item.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
|
||||||
</template>
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Check, ChevronDown, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
|
|
||||||
import {
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuRoot,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from 'reka-ui'
|
|
||||||
|
|
||||||
import type { AdminSessionDto } from '~/shared/types/telemetry'
|
|
||||||
|
|
||||||
import { type ThemeMode, useTheme } from '../../composables/use-theme'
|
|
||||||
|
|
||||||
defineProps<{ session: AdminSessionDto }>()
|
|
||||||
|
|
||||||
const { mode } = useTheme()
|
|
||||||
const themes: Array<{ value: ThemeMode; label: string; icon: typeof Sun }> = [
|
|
||||||
{ value: 'light', label: '浅色', icon: Sun },
|
|
||||||
{ value: 'dark', label: '深色', icon: Moon },
|
|
||||||
{ value: 'system', label: '跟随系统', icon: Monitor },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DropdownMenuRoot>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-2 px-2.5 text-sm outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex size-6 shrink-0 items-center justify-center rounded bg-primary/15 text-xs font-semibold text-primary"
|
|
||||||
>
|
|
||||||
{{ session.identity.name.slice(0, 1).toUpperCase() }}
|
|
||||||
</span>
|
|
||||||
<span class="hidden max-w-32 truncate sm:block">{{ session.identity.name }}</span>
|
|
||||||
<ChevronDown class="size-3.5 text-muted-foreground" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
:side-offset="6"
|
|
||||||
class="z-50 w-64 rounded-lg border border-surface-5 bg-popover p-1 text-popover-foreground shadow-xl outline-none"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel class="px-2 py-1.5">
|
|
||||||
<p class="truncate text-sm font-medium">{{ session.identity.name }}</p>
|
|
||||||
<p class="truncate text-xs font-normal text-muted-foreground">
|
|
||||||
{{ session.identity.email || 'Cloudflare Access 身份' }}
|
|
||||||
</p>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
|
||||||
<DropdownMenuLabel class="px-2 py-1 text-xs text-muted-foreground"
|
|
||||||
>界面主题</DropdownMenuLabel
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="theme in themes"
|
|
||||||
:key="theme.value"
|
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
|
|
||||||
@click="mode = theme.value"
|
|
||||||
>
|
|
||||||
<component :is="theme.icon" class="size-4 text-muted-foreground" />
|
|
||||||
<span class="flex-1">{{ theme.label }}</span>
|
|
||||||
<Check v-if="mode === theme.value" class="size-4" />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
|
||||||
<DropdownMenuItem as-child>
|
|
||||||
<a
|
|
||||||
:href="session.logoutUrl"
|
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
|
|
||||||
>
|
|
||||||
<LogOut class="size-4 text-muted-foreground" />
|
|
||||||
退出登录
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenuRoot>
|
|
||||||
</template>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ChevronDown } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
modelValue: string
|
|
||||||
label: string
|
|
||||||
options: Array<{ value: string; label: string }>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<label class="relative inline-flex min-w-0 items-center">
|
|
||||||
<span class="sr-only">{{ label }}</span>
|
|
||||||
<select
|
|
||||||
:value="modelValue"
|
|
||||||
class="h-9 max-w-full cursor-pointer appearance-none rounded-md border border-surface-4 bg-surface-2 py-0 pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
|
|
||||||
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<ChevronDown class="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { X } from 'lucide-vue-next'
|
|
||||||
import {
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogRoot,
|
|
||||||
DialogTitle,
|
|
||||||
} from 'reka-ui'
|
|
||||||
|
|
||||||
defineProps<{ open: boolean; title: string; description?: string }>()
|
|
||||||
defineEmits<{ 'update:open': [value: boolean] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogRoot :open="open" @update:open="$emit('update:open', $event)">
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
|
|
||||||
<DialogContent
|
|
||||||
class="sheet-panel fixed inset-y-0 right-0 z-50 flex w-full max-w-xl flex-col border-l border-surface-5 bg-surface-1 shadow-2xl focus:outline-none"
|
|
||||||
>
|
|
||||||
<header class="flex shrink-0 items-start justify-between gap-4 border-b px-5 py-4">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<DialogTitle class="text-base font-semibold">{{ title }}</DialogTitle>
|
|
||||||
<DialogDescription v-if="description" class="mt-1 text-sm text-muted-foreground">
|
|
||||||
{{ description }}
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
<DialogClose
|
|
||||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-surface-3 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
aria-label="关闭详情"
|
|
||||||
>
|
|
||||||
<X class="size-4" />
|
|
||||||
</DialogClose>
|
|
||||||
</header>
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
|
||||||
</template>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { cn } from '~/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps<{ class?: string }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="cn('animate-pulse rounded-md bg-muted', props.class)" aria-hidden="true"></div>
|
|
||||||
</template>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
modelValue: string
|
|
||||||
label: string
|
|
||||||
items: Array<{ value: string; label: string }>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
:aria-label="label"
|
|
||||||
class="inline-flex h-9 items-center rounded-md border border-surface-4 bg-surface-3 p-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.value"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
:aria-selected="modelValue === item.value"
|
|
||||||
class="h-7 cursor-pointer rounded px-2.5 text-xs font-medium text-muted-foreground transition-[color,background-color,box-shadow] hover:text-foreground active:bg-surface-4"
|
|
||||||
:class="modelValue === item.value && 'bg-surface-2 text-foreground shadow-sm'"
|
|
||||||
@click="$emit('update:modelValue', item.value)"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user