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
255 lines
7.7 KiB
Vue
255 lines
7.7 KiB
Vue
<script setup lang="ts">
|
|
import { HistoryIcon } from '@modrinth/assets'
|
|
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
|
import type { Dayjs } from 'dayjs'
|
|
import dayjs from 'dayjs'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
import {
|
|
getHomeWidgetCardDensity,
|
|
HOME_RECENT_DEFAULT_LIMIT,
|
|
type HomeRecentLimit,
|
|
type HomeWidgetSize,
|
|
} from '@/components/home/home-dashboard'
|
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
|
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
|
import { kill, run } from '@/helpers/instance'
|
|
import type { GameInstance } from '@/helpers/types'
|
|
import {
|
|
getWorldIdentifier,
|
|
hasServerQuickPlaySupport,
|
|
hasWorldQuickPlaySupport,
|
|
type ServerWorld,
|
|
start_join_server,
|
|
start_join_singleplayer_world,
|
|
type WorldWithInstance,
|
|
} from '@/helpers/worlds'
|
|
import { handleSevereError } from '@/store/error'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
instances: GameInstance[]
|
|
dashboard?: boolean
|
|
dashboardSize?: HomeWidgetSize | null
|
|
limit?: HomeRecentLimit
|
|
}>(),
|
|
{
|
|
limit: HOME_RECENT_DEFAULT_LIMIT,
|
|
},
|
|
)
|
|
|
|
const { handleError } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
|
const runtime = useHomeDashboardRuntime()
|
|
const { gameVersions, recentWorlds, runningInstanceIds } = runtime
|
|
|
|
const messages = defineMessages({
|
|
recentTitle: {
|
|
id: 'app.home.recent.title',
|
|
defaultMessage: 'Start from your recent projects',
|
|
},
|
|
emptyRecent: {
|
|
id: 'app.home.recent.empty',
|
|
defaultMessage: 'No recent activity yet.',
|
|
},
|
|
})
|
|
|
|
type RecentItem =
|
|
| { type: 'world'; last_played: Dayjs; instance: GameInstance; world: WorldWithInstance }
|
|
| { type: 'instance'; last_played: Dayjs; instance: GameInstance }
|
|
|
|
const startingWorldKey = ref<string | null>(null)
|
|
const playingWorldKey = ref<string | null>(null)
|
|
|
|
const instanceById = computed(
|
|
() => new Map(props.instances.map((instance) => [instance.id, instance])),
|
|
)
|
|
|
|
const recentItems = computed<RecentItem[]>(() => {
|
|
const worldItems: RecentItem[] = recentWorlds.value.flatMap((world) => {
|
|
const instance = instanceById.value.get(world.instance_id)
|
|
if (!instance || !world.last_played) return []
|
|
return [{ type: 'world', last_played: dayjs(world.last_played), instance, world }]
|
|
})
|
|
const coveredInstanceIds = new Set(worldItems.map((item) => item.instance.id))
|
|
const instanceItems: RecentItem[] = props.instances
|
|
.filter((instance) => instance.last_played && !coveredInstanceIds.has(instance.id))
|
|
.map((instance) => ({
|
|
type: 'instance',
|
|
last_played: dayjs(instance.last_played),
|
|
instance,
|
|
}))
|
|
|
|
return [...worldItems, ...instanceItems]
|
|
.sort((a, b) => b.last_played.diff(a.last_played))
|
|
.slice(0, props.limit)
|
|
})
|
|
const itemDensity = computed(() => getHomeWidgetCardDensity(props.dashboardSize))
|
|
|
|
function worldKey(world: WorldWithInstance): string {
|
|
return `${world.instance_id}:${world.type}:${getWorldIdentifier(world)}`
|
|
}
|
|
|
|
function serverDataFor(world: WorldWithInstance) {
|
|
return world.type === 'server'
|
|
? runtime.getServerData(world.instance_id, world.address)
|
|
: undefined
|
|
}
|
|
|
|
watch(runningInstanceIds, (instanceIds) => {
|
|
if (playingWorldKey.value && !instanceIds.includes(playingWorldKey.value.split(':', 1)[0])) {
|
|
playingWorldKey.value = null
|
|
}
|
|
})
|
|
|
|
async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|
const key = worldKey(world)
|
|
startingWorldKey.value = key
|
|
|
|
try {
|
|
if (world.type === 'server') {
|
|
await start_join_server(world.instance_id, world.address)
|
|
} else {
|
|
await start_join_singleplayer_world(world.instance_id, world.path)
|
|
}
|
|
playingWorldKey.value = key
|
|
} catch (error) {
|
|
const handled = await handleMinecraftLaunchError(error, {
|
|
instance_id: instance.id,
|
|
instance_name: instance.name,
|
|
})
|
|
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
|
} finally {
|
|
startingWorldKey.value = null
|
|
}
|
|
}
|
|
|
|
async function playInstance(instance: GameInstance) {
|
|
try {
|
|
await run(instance.id)
|
|
} catch (error) {
|
|
const handled = await handleMinecraftLaunchError(error, {
|
|
instance_id: instance.id,
|
|
instance_name: instance.name,
|
|
})
|
|
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
|
}
|
|
}
|
|
|
|
async function stopInstance(instance: GameInstance) {
|
|
await kill(instance.id).catch(handleError)
|
|
playingWorldKey.value = null
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
|
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
|
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
|
|
</div>
|
|
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
|
<template
|
|
v-for="item in recentItems"
|
|
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
|
|
>
|
|
<WorldItem
|
|
v-if="item.type === 'world'"
|
|
:world="item.world"
|
|
:playing-instance="runningInstanceIds.includes(item.instance.id)"
|
|
:playing-world="playingWorldKey === worldKey(item.world)"
|
|
:starting-instance="startingWorldKey === worldKey(item.world)"
|
|
:supports-server-quick-play="
|
|
item.world.type === 'server' &&
|
|
hasServerQuickPlaySupport(gameVersions, item.instance.game_version)
|
|
"
|
|
:supports-world-quick-play="
|
|
item.world.type === 'singleplayer' &&
|
|
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version)
|
|
"
|
|
:current-protocol="runtime.getProtocolVersion(item.instance.id)"
|
|
:refreshing="
|
|
item.world.type === 'server' ? serverDataFor(item.world)?.refreshing : undefined
|
|
"
|
|
:server-status="
|
|
item.world.type === 'server' ? serverDataFor(item.world)?.status : undefined
|
|
"
|
|
:rendered-motd="
|
|
item.world.type === 'server' ? serverDataFor(item.world)?.renderedMotd : undefined
|
|
"
|
|
:game-mode="
|
|
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
|
"
|
|
:instance-id="item.instance.id"
|
|
:instance-name="item.instance.name"
|
|
:instance-icon="item.instance.icon_path"
|
|
:instance-loader="item.instance.loader"
|
|
:shortcut-instance-id="item.instance.id"
|
|
:flat="dashboard"
|
|
:dashboard-density="itemDensity"
|
|
@play="joinWorld(item.world, item.instance)"
|
|
@play-instance="playInstance(item.instance)"
|
|
@stop="stopInstance(item.instance)"
|
|
@refresh="
|
|
item.world.type === 'server'
|
|
? runtime.refreshServer(item.instance.id, (item.world as ServerWorld).address, true)
|
|
: undefined
|
|
"
|
|
@update="runtime.refreshRecentWorlds"
|
|
/>
|
|
<InstanceItem
|
|
v-else
|
|
:instance="item.instance"
|
|
:last-played="item.last_played"
|
|
:flat="dashboard"
|
|
:playing="runningInstanceIds.includes(item.instance.id)"
|
|
:dashboard-density="itemDensity"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div v-else class="home-widget-empty">
|
|
<HistoryIcon aria-hidden="true" />
|
|
<span>{{ formatMessage(messages.emptyRecent) }}</span>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.home-widget-heading h2 {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
color: var(--color-contrast);
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.home-recent-worlds[data-size='2x1'] {
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.home-widget-empty {
|
|
display: flex;
|
|
max-width: 20rem;
|
|
margin: auto;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--color-secondary);
|
|
font-size: 0.8125rem;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
}
|
|
|
|
.home-widget-empty svg {
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
opacity: 0.7;
|
|
}
|
|
</style>
|