Files
Starlight_Lancher/apps/app-frontend/src/components/ui/settings/AboutSettings.vue
Ap_Tx 6fa9461d2d
Some checks failed
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:添加了神秘合成下界之星小彩蛋
2026-09-13 14:27:35 +08:00

388 lines
12 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 { 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, teamMembers, type TeamMember } 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<AboutMemberExperience>()
const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | 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 () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
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)
let typedBuffer = ''
const secretCodes = ['starlight']
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 (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 w-full overflow-hidden h-64 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 />
<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"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<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" />
<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);
}
.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>