Files
Starlight_Lancher/apps/app-frontend/src/components/home/HomeShortcutWidget.vue
Disy920 fa96226beb
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
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
refactor: remove telemetry and fix launcher workflows
2026-09-13 20:32:57 +08:00

452 lines
14 KiB
Vue

<script setup lang="ts">
import {
BoxIcon,
GameIcon,
IssuesIcon,
NoSignalIcon,
PlayIcon,
ServerIcon,
SignalIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
GAME_MODES,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
getWorldIdentifier,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
start_join_server,
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
placement: HomeWidgetPlacement
instances: GameInstance[]
dashboardSize: HomeWidgetSize
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { gameVersions, runningInstanceIds } = runtime
const world = ref<World | null>(null)
const starting = ref(false)
const loadingTarget = ref(false)
const messages = defineMessages({
unavailable: {
id: 'app.home.widgets.unavailable',
defaultMessage: 'Content unavailable',
},
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
instance: { id: 'app.home.shortcut.kind.instance', defaultMessage: 'Instance' },
world: { id: 'app.home.shortcut.kind.world', defaultMessage: 'World' },
server: { id: 'app.home.shortcut.kind.server', defaultMessage: 'Server' },
offline: { id: 'app.home.shortcut.server.offline', defaultMessage: 'Server offline' },
playersOnline: {
id: 'app.home.shortcut.server.players-online',
defaultMessage: '{count} online',
},
hardcore: { id: 'instance.worlds.hardcore', defaultMessage: 'Hardcore mode' },
noServerQuickPlay: {
id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'Direct server join is unavailable for this Minecraft version.',
},
noWorldQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'Direct world launch is unavailable for this Minecraft version.',
},
})
const instance = computed(() =>
props.instances.find((candidate) => candidate.id === props.placement.target?.instanceId),
)
const missing = computed(
() => !instance.value || (props.placement.kind !== 'instance' && !world.value),
)
const serverData = computed(() =>
instance.value && world.value?.type === 'server'
? runtime.getServerData(instance.value.id, world.value.address)
: undefined,
)
const isRunning = computed(() =>
instance.value ? runningInstanceIds.value.includes(instance.value.id) : false,
)
const versionLabel = computed(() => {
if (!instance.value) return ''
const loader = instance.value.loader === 'vanilla' ? 'Minecraft' : instance.value.loader
return `${loader} ${instance.value.game_version}`
})
const lastPlayedLabel = computed(() => {
const lastPlayed = world.value?.last_played ?? instance.value?.last_played
return lastPlayed
? formatMessage(messages.played, {
time: formatRelativeTime(dayjs(lastPlayed).toISOString()),
})
: formatMessage(messages.neverPlayed)
})
const shortcutTitle = computed(
() =>
(props.placement.kind === 'instance' ? instance.value?.name : world.value?.name) ??
props.placement.target?.fallbackLabel ??
'',
)
const shortcutRoute = computed(() => {
if (!instance.value) return '/'
if (!world.value) return `/instance/${encodeURIComponent(instance.value.id)}`
return `/instance/${encodeURIComponent(instance.value.id)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world.value))}`
})
const shortcutIcon = computed(() => {
if (!world.value) return undefined
return world.value.type === 'server'
? (serverData.value?.status?.favicon ?? world.value.icon)
: world.value.icon
})
const kindLabel = computed(() =>
formatMessage(
props.placement.kind === 'instance'
? messages.instance
: props.placement.kind === 'world'
? messages.world
: messages.server,
),
)
const kindIcon = computed(() =>
props.placement.kind === 'instance'
? BoxIcon
: props.placement.kind === 'world'
? GameIcon
: ServerIcon,
)
const primaryLabel = computed(() => {
if (!world.value) return versionLabel.value
if (world.value.type === 'singleplayer') {
return world.value.hardcore
? formatMessage(messages.hardcore)
: formatMessage(GAME_MODES[world.value.game_mode].message)
}
if (serverData.value?.refreshing) return formatMessage(commonMessages.loadingLabel)
if (!serverData.value?.status) return formatMessage(messages.offline)
return formatMessage(messages.playersOnline, {
count: serverData.value.status.players?.online ?? 0,
})
})
const secondaryLabel = computed(() => {
if (world.value?.type === 'server') return world.value.address
if (world.value) return `${instance.value?.name ?? ''} · ${lastPlayedLabel.value}`
return lastPlayedLabel.value
})
const statusIcon = computed(() => {
if (world.value?.type !== 'server') return world.value ? GameIcon : TimerIcon
return serverData.value?.status ? SignalIcon : NoSignalIcon
})
const supportsQuickPlay = computed(() => {
if (!world.value || !instance.value) return true
return world.value.type === 'server'
? hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version)
: hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version)
})
const playTooltip = computed(() => {
if (supportsQuickPlay.value) return formatMessage(commonMessages.playButton)
return formatMessage(
world.value?.type === 'server' ? messages.noServerQuickPlay : messages.noWorldQuickPlay,
)
})
async function refreshTarget(force = false) {
world.value = null
const target = props.placement.target
if (!target || props.placement.kind === 'instance' || !instance.value) return
loadingTarget.value = true
try {
const available = await runtime.getInstanceWorlds(target.instanceId, force)
world.value =
available.find((candidate) =>
candidate.type === 'server'
? props.placement.kind === 'server' && candidate.address === target.address
: props.placement.kind === 'world' && candidate.path === target.path,
) ?? null
if (world.value?.type === 'server') {
await runtime.refreshServer(target.instanceId, world.value.address, force)
}
} finally {
loadingTarget.value = false
}
}
async function playInstance(targetInstance: GameInstance) {
starting.value = true
try {
await run(targetInstance.id)
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: targetInstance.id,
instance_name: targetInstance.name,
})
if (!handled) handleSevereError(error, { instanceId: targetInstance.id })
} finally {
starting.value = false
}
}
async function playWorld() {
if (!instance.value || !world.value) return
starting.value = true
try {
if (world.value.type === 'server') {
await start_join_server(instance.value.id, world.value.address)
} else {
await start_join_singleplayer_world(instance.value.id, world.value.path)
}
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.value.id,
instance_name: instance.value.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.value.id })
} finally {
starting.value = false
}
}
async function playShortcut() {
if (!instance.value) return
if (world.value) await playWorld()
else await playInstance(instance.value)
}
async function stopInstance() {
if (!instance.value) return
await kill(instance.value.id).catch(handleError)
}
watch(
() => [props.placement, props.instances] as const,
() => refreshTarget(),
{
immediate: true,
deep: true,
},
)
</script>
<template>
<div
class="home-shortcut-widget min-w-0 min-h-0 h-full"
:data-size="dashboardSize"
:data-kind="placement.kind"
>
<div
v-if="loadingTarget"
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
>
<SpinnerIcon class="size-6 animate-spin text-secondary" aria-hidden="true" />
<span class="text-sm text-secondary">{{ formatMessage(commonMessages.loadingLabel) }}</span>
</div>
<div
v-else-if="missing"
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
>
<IssuesIcon class="size-6 text-secondary" aria-hidden="true" />
<strong class="max-w-full truncate text-contrast">{{
placement.target?.fallbackLabel
}}</strong>
<span class="text-sm text-secondary">{{ formatMessage(messages.unavailable) }}</span>
</div>
<div v-else class="home-shortcut-card grid min-w-0 min-h-0 h-full overflow-hidden">
<router-link
class="home-shortcut-visual relative flex min-w-0 min-h-0 items-center justify-center overflow-hidden bg-button-bg text-secondary no-underline"
:to="shortcutRoute"
tabindex="-1"
>
<component
:is="kindIcon"
class="home-shortcut-watermark absolute -bottom-3 right-3 size-[4.5rem] opacity-[0.08]"
aria-hidden="true"
/>
<Avatar
v-if="shortcutIcon"
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
:src="shortcutIcon"
:size="dashboardSize === '2x1' ? '72px' : '44px'"
/>
<InstanceIcon
v-else-if="instance"
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:size="dashboardSize === '2x1' ? '72px' : '44px'"
/>
</router-link>
<div class="home-shortcut-body relative flex min-w-0 min-h-0 items-stretch">
<router-link
class="home-shortcut-copy flex min-w-0 flex-1 flex-col text-inherit no-underline"
:to="shortcutRoute"
>
<span
class="home-shortcut-kind flex min-w-0 items-center gap-[0.3rem] text-secondary text-[0.6875rem] font-bold leading-none"
>
<component :is="kindIcon" aria-hidden="true" />
{{ kindLabel }}
</span>
<strong
class="home-shortcut-title min-w-0 truncate text-contrast font-[750]"
>{{ shortcutTitle }}</strong
>
<span
class="home-shortcut-meta home-shortcut-primary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
>
<SpinnerIcon
v-if="world?.type === 'server' && serverData?.refreshing"
class="animate-spin"
aria-hidden="true"
/>
<component :is="statusIcon" v-else aria-hidden="true" />
{{ primaryLabel }}
</span>
<span
class="home-shortcut-meta home-shortcut-secondary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
>
<TimerIcon v-if="world?.type !== 'server'" aria-hidden="true" />
<ServerIcon v-else aria-hidden="true" />
{{ secondaryLabel }}
</span>
</router-link>
<div class="absolute bottom-3 right-3 z-[2]">
<ButtonStyled v-if="isRunning" circular size="small" color="red">
<button v-tooltip="formatMessage(commonMessages.stopButton)" @click="stopInstance">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else circular size="small" color="brand">
<button
v-tooltip="playTooltip"
:disabled="starting || !supportsQuickPlay"
@click="playShortcut"
>
<SpinnerIcon v-if="starting" class="animate-spin" />
<PlayIcon v-else class="translate-x-px" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home-shortcut-copy:focus-visible {
border-radius: 6px;
outline: 4px solid var(--color-brand-shadow);
outline-offset: 2px;
}
.home-shortcut-kind svg,
.home-shortcut-meta svg {
width: 0.8rem;
height: 0.8rem;
flex: 0 0 auto;
}
.home-shortcut-copy:hover .home-shortcut-title {
text-decoration: underline;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-card {
grid-template-rows: 3.75rem minmax(0, 1fr);
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-visual {
justify-content: flex-start;
padding: 0 0.875rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-watermark {
right: 0.5rem;
bottom: -1.25rem;
width: 4rem;
height: 4rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-body {
padding: 0.625rem 0.75rem 0.75rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-copy {
padding-right: 2.5rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-kind {
display: none;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-title {
font-size: 0.9375rem;
line-height: 1.2;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-primary {
margin-top: 0.3rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-secondary {
display: none;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-card {
grid-template-columns: minmax(8.5rem, 0.8fr) minmax(0, 1.65fr);
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-body {
padding: 1rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-copy {
justify-content: center;
padding-right: 3rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-title {
margin-top: 0.4rem;
font-size: 1.125rem;
line-height: 1.25;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-primary {
margin-top: 0.65rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-secondary {
margin-top: 0.3rem;
}
</style>