Files
Starlight_Lancher/apps/app-frontend/src/components/ui/settings/AboutSettings.vue
T
Disy bc904065c3
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

420 lines
13 KiB
Vue

<script setup lang="ts">
import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
const { formatMessage } = useVIntl()
const version = await getVersion()
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
const pressingMemberName = ref<string>()
let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
const licenseUrl = `${AxolotlBrandConfig.repositoryUrl}/blob/main/LICENSE`
const thirdPartyLicensesUrl = `${AxolotlBrandConfig.repositoryUrl}/tree/main/third-party/licenses`
function cancelMemberLongPress() {
if (longPressTimer) window.clearTimeout(longPressTimer)
longPressTimer = undefined
pressingMemberName.value = undefined
}
function startMemberLongPress(member: TeamMember, event: PointerEvent) {
const experience = getAboutMemberExperience(member.experience)
if (!experience || event.button !== 0) return
cancelMemberLongPress()
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
suppressNextMemberClick = true
cancelMemberLongPress()
if (experience.kind === 'color-mine') {
openColorMine()
return
}
activeMemberExperience.value = experience
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
}
function moveMemberLongPress(event: PointerEvent) {
if (!longPressTimer) return
if (Math.hypot(event.clientX - pressStart.x, event.clientY - pressStart.y) > 8) {
cancelMemberLongPress()
}
}
function handleMemberClick(event: MouseEvent) {
if (!suppressNextMemberClick) return
suppressNextMemberClick = false
event.preventDefault()
event.stopPropagation()
}
function handleMemberContextMenu(member: TeamMember, event: MouseEvent) {
if (getAboutMemberExperience(member.experience)) event.preventDefault()
}
function closeMemberExperience() {
activeMemberExperience.value = undefined
}
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | 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 = ''
const secretCodes = ['starlight']
const YUANSHEN_URL = 'https://ys.mihoyo.com/cloud/'
const konamiSequence = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'KeyB',
'KeyA',
]
let konamiIndex = 0
function handleEasterEggKeydown(event: KeyboardEvent) {
typedBuffer = (typedBuffer + event.key).toLowerCase()
const maxCodeLen = Math.max(...secretCodes.map((c) => c.length))
if (typedBuffer.length > maxCodeLen) {
typedBuffer = typedBuffer.slice(-maxCodeLen)
}
if (typedBuffer.endsWith('yuanshen')) {
typedBuffer = ''
openUrl(YUANSHEN_URL).catch(() => {})
return
}
if (secretCodes.some((code) => typedBuffer.endsWith(code))) {
typedBuffer = ''
gameModal.value?.show()
return
}
const expected = konamiSequence[konamiIndex]
if (event.code === expected) {
konamiIndex++
if (konamiIndex === konamiSequence.length) {
konamiIndex = 0
contributorsModal.value?.show()
}
} else {
konamiIndex = event.code === konamiSequence[0] ? 1 : 0
}
}
function onEasterEggOpenGame() {
gameModal.value?.show()
}
document.addEventListener('keydown', handleEasterEggKeydown)
onScopeDispose(() => document.removeEventListener('keydown', handleEasterEggKeydown))
onScopeDispose(cancelMemberLongPress)
const messages = defineMessages({
productTitle: {
id: 'app.settings.about.product-title',
defaultMessage: 'About {productName}',
},
productDescription: {
id: 'app.settings.about.description',
defaultMessage: 'Starlight Launcher',
},
copyright: {
id: 'app.settings.about.copyright',
defaultMessage: 'Copyright © Axolotl All Rights Reserved.',
},
version: {
id: 'app.settings.about.version',
defaultMessage: 'Version {version}',
},
replayOnboarding: {
id: 'app.settings.about.replay-onboarding',
defaultMessage: 'Replay tour',
},
developmentTeam: {
id: 'app.settings.about.development-team',
defaultMessage: 'Development team',
},
licenseAttribution: {
id: 'app.settings.about.license-attribution',
defaultMessage: 'License & attribution',
},
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.',
},
notAffiliated: {
id: 'app.settings.about.not-affiliated',
defaultMessage:
'Modrinth is a trademark of Rinth, Inc. Starlight Launcher is not affiliated with or endorsed by Rinth, Inc.',
},
originalSource: {
id: 'app.settings.about.original-source',
defaultMessage: 'Original Modrinth source',
},
projectLicense: {
id: 'app.settings.about.project-license',
defaultMessage: 'Project license (GPL-3.0)',
},
thirdPartyLicenses: {
id: 'app.settings.about.third-party-licenses',
defaultMessage: 'Third-party licenses',
},
contributors: {
id: 'app.settings.about.contributors',
defaultMessage: 'Contributors',
},
contributorsCount: {
id: 'app.settings.about.contributors-count',
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
},
})
</script>
<template>
<div class="about-page flex flex-col gap-6">
<section id="settings-target-about-product" tabindex="-1" class="about-panel">
<div class="flex flex-col items-center gap-4">
<div
ref="experienceHost"
class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
"
>
<AboutScene :paused="Boolean(activeMemberExperience)" />
<component
:is="activeMemberExperience?.component"
v-if="activeMemberExperience"
@exit="closeMemberExperience"
/>
</div>
<div class="min-w-0 text-center">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{
formatMessage(messages.productTitle, {
productName: AxolotlBrandConfig.productName,
})
}}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.version, { version }) }}
</p>
</div>
</div>
<p class="m-0 mt-3 text-center text-primary">
{{ formatMessage(messages.productDescription) }}
</p>
<p class="m-0 mt-2 text-center text-xs text-secondary">
{{ formatMessage(messages.copyright) }}
</p>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" />
{{ formatMessage(messages.licenseAttribution) }}
</h3>
<div class="about-panel about-panel-compact">
<p class="m-0 text-primary">
{{ formatMessage(messages.attribution) }}
</p>
<p class="m-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.notAffiliated) }}
</p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a
:href="licenseUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.projectLicense) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
:href="thirdPartyLicensesUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.thirdPartyLicenses) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
href="https://github.com/modrinth/code"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.originalSource) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
</div>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<UsersIcon class="size-5 text-secondary" />
{{ formatMessage(messages.developmentTeam) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
:class="{ 'member-card-holding': pressingMemberName === member.name }"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@blur="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
@dragstart.prevent
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<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>
</section>
<details class="group pt-4 about-settings-details">
<summary
class="flex cursor-pointer list-none items-center gap-2 text-base font-semibold text-contrast [&::-webkit-details-marker]:hidden"
>
<UsersIcon class="size-5 text-secondary" />
<span>{{ formatMessage(messages.contributors) }}</span>
<span class="rounded-full bg-surface-4 px-2 py-0.5 text-xs text-secondary">
{{ formatMessage(messages.contributorsCount, { count: contributors.length }) }}
</span>
<ChevronDownIcon
class="ml-auto size-5 text-secondary transition-transform group-open:rotate-180"
/>
</summary>
<div class="mt-3 flex flex-wrap gap-2">
<a
v-for="contributor in contributors"
:key="contributor.name"
:href="contributor.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4 py-1 pl-1 pr-2.5 transition-colors hover:bg-surface-5"
>
<Avatar
:src="contributor.avatarUrl"
:alt="contributor.name"
size="1.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="truncate text-sm text-primary">{{ contributor.name }}</span>
</a>
</div>
</details>
<div id="settings-target-about-replay-tour" tabindex="-1" class="flex flex-wrap gap-2">
<Button type="base" @click="replayOnboarding?.('main')">
{{ formatMessage(messages.replayOnboarding) }}
</Button>
</div>
</div>
<EasterEggGameModal ref="gameModal" />
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template>
<style scoped>
.about-settings-details {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
.about-panel {
padding: 1.25rem;
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);
}
.about-panel-compact {
padding: var(--gap-lg);
}
.member-card-holding {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.about-page :deep(.rounded-xl.bg-surface-4) {
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);
}
.about-page :deep(.rounded-xl.bg-surface-2) {
border-radius: var(--radius-sm);
}
</style>