Files
Starlight_Lancher/apps/app-frontend/src/components/home/HomePinnedServers.vue

324 lines
8.5 KiB
Vue

<script setup lang="ts">
import {
MoreVerticalIcon,
NoSignalIcon,
PinIcon,
PlayIcon,
ServerIcon,
SignalIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
defineMessages,
injectNotificationManager,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { kill } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
type ServerWorld,
set_world_display_status,
start_join_server,
type WorldWithInstance,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
instances: GameInstance[]
dashboard?: boolean
dashboardSize?: HomeWidgetSize | null
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { favoriteWorlds, runningInstanceIds } = runtime
const messages = defineMessages({
pinnedServers: {
id: 'app.home.servers.pinned',
defaultMessage: 'Pinned servers',
},
emptyServers: {
id: 'app.home.servers.empty',
defaultMessage: 'Favorite a server and it will be pinned here.',
},
playersOnline: {
id: 'app.home.servers.players-online',
defaultMessage: '{online}/{max} online',
},
offline: {
id: 'app.home.servers.offline',
defaultMessage: 'Offline',
},
join: {
id: 'app.home.servers.join',
defaultMessage: 'Join server',
},
stop: {
id: 'app.home.servers.stop',
defaultMessage: 'Stop',
},
unpin: {
id: 'app.home.servers.unpin',
defaultMessage: 'Unpin from Home',
},
moreOptions: {
id: 'app.home.servers.more-options',
defaultMessage: 'More options',
},
})
const startingServerKey = ref<string | null>(null)
const instanceById = computed(
() => new Map(props.instances.map((instance) => [instance.id, instance])),
)
const servers = computed(() =>
favoriteWorlds.value.flatMap((world) => {
if (world.type !== 'server') return []
const instance = instanceById.value.get(world.instance_id)
return instance ? [{ instance, world: world as ServerWorld & WorldWithInstance }] : []
}),
)
function serverKey(world: ServerWorld & WorldWithInstance): string {
return `${world.instance_id}:${world.address}`
}
function dataFor(world: ServerWorld & WorldWithInstance) {
return runtime.getServerData(world.instance_id, world.address)
}
async function joinServer(world: ServerWorld & WorldWithInstance, instance: GameInstance) {
const key = serverKey(world)
startingServerKey.value = key
try {
await start_join_server(world.instance_id, world.address)
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
startingServerKey.value = null
}
}
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
}
async function unpinServer(world: ServerWorld & WorldWithInstance) {
await set_world_display_status(world.instance_id, 'server', world.address, 'normal').catch(
handleError,
)
await runtime.refreshFavorites()
}
</script>
<template>
<section
class="home-pinned-servers 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">
<ServerIcon class="size-5 shrink-0 text-brand" aria-hidden="true" />
<h2>{{ formatMessage(messages.pinnedServers) }}</h2>
</div>
<div v-if="servers.length === 0" class="home-widget-empty">
<ServerIcon aria-hidden="true" />
<span>{{ formatMessage(messages.emptyServers) }}</span>
</div>
<ul
v-else
class="home-server-list grid min-w-0 min-h-0 flex-1 grid-auto-rows-max gap-1 m-0 overflow-x-hidden overflow-y-auto pr-1 list-none"
>
<li
v-for="server in servers"
:key="serverKey(server.world)"
class="home-server-row group hover:bg-button-bg focus-within:bg-button-bg"
>
<div class="relative shrink-0">
<Avatar
:src="dataFor(server.world).status?.favicon ?? (server.world.icon || undefined)"
:tint-by="server.world.address"
size="36px"
/>
<span
class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-solid border-bg-raised"
:class="
dataFor(server.world).refreshing
? 'animate-pulse bg-secondary'
: dataFor(server.world).status
? 'bg-brand-green'
: 'bg-red'
"
aria-hidden="true"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-contrast">
{{ server.world.name }}
</span>
<span class="truncate text-xs text-secondary">{{ server.world.address }}</span>
<span
v-if="dataFor(server.world).status"
class="flex min-w-0 items-center gap-1 text-xs text-secondary"
>
<SignalIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">
{{
formatMessage(messages.playersOnline, {
online: dataFor(server.world).status?.players?.online ?? 0,
max: dataFor(server.world).status?.players?.max ?? 0,
})
}}
</span>
</span>
<span
v-else-if="dataFor(server.world).refreshing"
class="truncate text-xs text-secondary"
>
{{ server.world.address }}
</span>
<span v-else class="flex min-w-0 items-center gap-1 text-xs text-secondary">
<NoSignalIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">{{ formatMessage(messages.offline) }}</span>
</span>
</div>
<div class="ml-auto flex shrink-0 items-center gap-0.5">
<ButtonStyled
v-if="runningInstanceIds.includes(server.instance.id)"
circular
size="small"
type="transparent"
>
<button
v-tooltip="formatMessage(messages.stop)"
class="!text-red"
@click="stopInstance(server.instance)"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.join)"
class="!text-brand opacity-60 transition-opacity group-hover:opacity-100"
:disabled="startingServerKey === serverKey(server.world)"
@click="joinServer(server.world, server.instance)"
>
<SpinnerIcon
v-if="startingServerKey === serverKey(server.world)"
class="animate-spin"
/>
<PlayIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
circular
size="small"
type="transparent"
class="home-server-menu"
>
<OverflowMenu
:options="[
{
id: 'unpin',
action: () => unpinServer(server.world),
},
]"
:tooltip="formatMessage(messages.moreOptions)"
>
<MoreVerticalIcon />
<template #unpin>
<PinIcon class="rotate-45" aria-hidden="true" />
{{ formatMessage(messages.unpin) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</li>
</ul>
</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-server-row {
display: flex;
min-width: 0;
align-items: center;
gap: 0.625rem;
padding: 0.5rem;
border-radius: 6px;
transition: background-color 120ms ease;
}
.home-pinned-servers[data-size='2x1'] .home-server-list,
.home-pinned-servers[data-size='2x2'] .home-server-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 0.5rem;
}
.home-pinned-servers[data-size='1x1'] {
gap: 0.375rem;
}
.home-pinned-servers[data-size='1x1'] .home-widget-heading {
height: 1.5rem;
}
.home-pinned-servers[data-size='1x1'] .home-server-row {
gap: 0.5rem;
padding: 0.375rem;
}
.home-pinned-servers[data-size='1x1'] .home-server-menu {
display: none;
}
.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>