Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) ba83bd0a8b feat: server play frontend draft 2026-08-24 11:36:08 +01:00
14 changed files with 693 additions and 1 deletions
@@ -22,6 +22,7 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_offline_collapsed: true,
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
ServerPlayFrontend: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -9,6 +9,7 @@
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="appSettings.devMode"
:show-play-tab="appSettings.getFeatureFlag('ServerPlayFrontend')"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ServersManagePlayPage } from '@modrinth/ui'
import { useRoute, useRouter } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
const route = useRoute()
const router = useRouter()
const appSettings = useAppSettings()
if (!appSettings.getFeatureFlag('ServerPlayFrontend')) {
await router.replace(`/hosting/manage/${String(route.params.id)}`)
}
</script>
<template>
<ServersManagePlayPage />
</template>
@@ -4,5 +4,6 @@ import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
import Play from './Play.vue'
export { Access, Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview, Play }
+5
View File
@@ -26,6 +26,11 @@ export default new createRouter({
name: 'ServerManage',
component: Hosting.Index,
children: [
{
path: 'play',
name: 'ServerManagePlay',
component: Hosting.Play,
},
{
path: '',
name: 'ServerManageOverview',
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
dismissedPhotosensitivityFilterWarning: false,
ServerPlayFrontend: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -5,6 +5,7 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="flags.developerMode"
:show-advanced-debug-info="flags.advancedDebugInfo"
:show-play-tab="flags.ServerPlayFrontend"
:stripe-publishable-key="config.public.stripePublishableKey as string"
:site-url="config.public.siteUrl as string"
:products="products"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManagePlayPage } from '@modrinth/ui'
const { server } = injectModrinthServerContext()
const flags = useFeatureFlags()
const route = useNativeRoute()
if (!flags.value.ServerPlayFrontend) {
await navigateTo(`/hosting/manage/${String(route.params.id)}`, { replace: true })
}
useHead({
title: computed(() => `Play - ${server.value?.name ?? 'Server'} - Modrinth`),
})
</script>
<template>
<ServersManagePlayPage />
</template>
@@ -0,0 +1,175 @@
<template>
<section
class="flex flex-col gap-4 rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-5"
>
<div class="flex flex-col gap-8 md:flex-row md:items-center">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.playWithAppTitle) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.playWithAppDescription) }}
</p>
</div>
<div class="flex shrink-0 flex-col gap-2 md:w-[164px]">
<ButtonStyled color="brand">
<button class="!h-10 w-full" @click="emit('play')">
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playServerButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!h-10 w-full" @click="emit('invite')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.invitePlayersButton) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="h-px w-full bg-surface-4"></div>
<div class="flex flex-col gap-0.5">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.differentLauncherTitle) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.differentLauncherDescription) }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="joined-buttons shrink-0">
<ButtonStyled>
<a
v-if="modpackDownloadUrl"
class="!h-10"
:href="modpackDownloadUrl"
:download="modpackFilename"
>
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadModpackButton) }}
</a>
<button v-else type="button" class="!h-10">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadModpackButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a
v-if="modpackDownloadUrl"
v-tooltip="formatMessage(messages.downloadModpackButton)"
class="!h-10 !w-10 !px-0"
:href="modpackDownloadUrl"
:download="modpackFilename"
:aria-label="formatMessage(messages.downloadModpackButton)"
>
<ChevronDownIcon aria-hidden="true" />
</a>
<button
v-else
v-tooltip="formatMessage(messages.downloadModpackButton)"
type="button"
class="!h-10 !w-10 !px-0"
:aria-label="formatMessage(messages.downloadModpackButton)"
>
<ChevronDownIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<div
class="flex h-10 min-w-0 items-center justify-between gap-3 rounded-xl bg-surface-2 px-4 sm:w-[313px]"
>
<span class="min-w-0 truncate font-semibold text-primary">{{ address }}</span>
<button
v-tooltip="formatMessage(copied ? messages.copiedAddress : messages.copyAddress)"
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 text-primary"
:aria-label="formatMessage(copied ? messages.copiedAddress : messages.copyAddress)"
@click="copyAddress"
>
<CheckIcon v-if="copied" class="size-5 text-brand" aria-hidden="true" />
<CopyIcon v-else class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
DownloadIcon,
PlayIcon,
UserPlusIcon,
} from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const props = defineProps<{
address: string
modpackDownloadUrl?: string
modpackFilename?: string
}>()
const emit = defineEmits<{
play: []
invite: []
}>()
const { formatMessage } = useVIntl()
const copied = ref(false)
const messages = defineMessages({
playWithAppTitle: {
id: 'servers.play.card.app.title',
defaultMessage: 'Play with Modrinth App',
},
playWithAppDescription: {
id: 'servers.play.card.app.description',
defaultMessage:
'The easiest way to play is with the Modrinth App, which automatically installs all the required content for you and the players on your server.',
},
playServerButton: {
id: 'servers.play.card.app.play-button',
defaultMessage: 'Play server',
},
invitePlayersButton: {
id: 'servers.play.card.app.invite-button',
defaultMessage: 'Invite players',
},
differentLauncherTitle: {
id: 'servers.play.card.launcher.title',
defaultMessage: 'Using a Different Launcher?',
},
differentLauncherDescription: {
id: 'servers.play.card.launcher.description',
defaultMessage:
'Install the required content using your preferred launcher, then add the server address in-game to join.',
},
downloadModpackButton: {
id: 'servers.play.card.launcher.download-button',
defaultMessage: 'Download modpack',
},
copyAddress: {
id: 'servers.play.card.address.copy',
defaultMessage: 'Copy server address',
},
copiedAddress: {
id: 'servers.play.card.address.copied',
defaultMessage: 'Server address copied',
},
})
async function copyAddress() {
await navigator.clipboard.writeText(props.address)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
</script>
@@ -0,0 +1,200 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 md:flex-row">
<StyledInput
v-model="search"
:icon="SearchIcon"
:placeholder="`Search ${rows.length} users...`"
wrapper-class="min-w-0 flex-1"
input-class="!h-10"
clearable
/>
<Combobox
v-model="methodFilter"
class="md:!w-auto md:shrink-0"
:options="methodFilterOptions"
:display-value="selectedMethodFilterLabel"
trigger-class="min-w-[176px] !h-10 !min-h-10 !py-0"
>
<template #prefix>
<FilterIcon class="size-5 text-secondary" aria-hidden="true" />
</template>
</Combobox>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedRows"
row-key="id"
table-min-width="50rem"
@sort="handleSort"
>
<template #empty-state>
<div class="flex h-64 items-center justify-center px-4 text-center text-secondary">
No users match your filters.
</div>
</template>
<template #cell-username="{ row }">
<div class="flex min-w-0 max-w-full items-center gap-2">
<Avatar
:src="row.avatarUrl"
:alt="`${row.username}'s avatar`"
:tint-by="row.username"
size="24px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium text-primary">{{ row.username }}</span>
</div>
</template>
<template #cell-lastPlayed="{ row }">
<span v-if="row.lastPlayedAt" v-tooltip="formatDateTime(row.lastPlayedAt)">
{{ formatPlayerDate(row.lastPlayedAt) }}
</span>
<span v-else>Never</span>
</template>
<template #cell-joined="{ row }">
<span
v-if="row.pending"
class="inline-flex h-7 items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
Pending
</span>
<span v-else-if="row.joinedAt" v-tooltip="formatDateTime(row.joinedAt)">
{{ formatPlayerDate(row.joinedAt) }}
</span>
</template>
<template #cell-method="{ row }">
<span class="inline-flex min-w-0 items-center gap-2">
<UserPlusIcon v-if="row.method === 'direct'" class="size-5 shrink-0" />
<LinkIcon v-else class="size-5 shrink-0" />
<span class="min-w-0 truncate">{{ methodLabel(row.method) }}</span>
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled circular type="transparent">
<button
v-tooltip="row.pending ? 'Revoke invite' : 'More actions'"
:aria-label="`${row.pending ? 'Revoke invite' : 'More actions'} for ${row.username}`"
class="text-secondary hover:!filter-none"
:class="row.pending ? 'hover:text-red' : ''"
@click="row.pending ? emit('remove', row) : emit('open-actions', row)"
>
<XIcon v-if="row.pending" aria-hidden="true" />
<MoreHorizontalIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
</div>
</template>
<script setup lang="ts">
import {
FilterIcon,
LinkIcon,
MoreHorizontalIcon,
SearchIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import Table, { type SortDirection, type TableColumn } from '#ui/components/base/Table.vue'
import { useFormatDateTime, useRelativeTime } from '#ui/composables'
import type { ServerPlayerMethod, ServerPlayerRow } from './types'
type MethodFilter = ServerPlayerMethod | 'all'
type PlayerColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
const props = defineProps<{ rows: ServerPlayerRow[] }>()
const emit = defineEmits<{
remove: [row: ServerPlayerRow]
'open-actions': [row: ServerPlayerRow]
}>()
const search = ref('')
const methodFilter = ref<MethodFilter>('all')
const sortColumn = ref<string | undefined>('joined')
const sortDirection = ref<SortDirection>('desc')
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const methodFilterOptions: ComboboxOption<MethodFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'direct', label: 'Direct invite' },
{ value: 'link', label: 'Shareable link' },
]
const selectedMethodFilterLabel = computed(
() =>
`Method: ${
methodFilterOptions.find((option) => option.value === methodFilter.value)?.label ?? 'All'
}`,
)
const columns: TableColumn<PlayerColumn>[] = [
{ key: 'username', label: 'Username', enableSorting: true },
{ key: 'lastPlayed', label: 'Last played', enableSorting: true },
{ key: 'joined', label: 'Joined', enableSorting: true, defaultSortDirection: 'desc' },
{ key: 'method', label: 'Method', enableSorting: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase()
return props.rows.filter((row) => {
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
if (!query) return true
return [row.username, methodLabel(row.method)].some((value) =>
value.toLowerCase().includes(query),
)
})
})
const sortedRows = computed(() => [...filteredRows.value].sort(compareRows))
function methodLabel(method: ServerPlayerMethod) {
return method === 'link' ? 'Shareable link' : 'Direct invite'
}
function formatPlayerDate(date: Date) {
const today = new Date()
if (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
) {
return 'Today'
}
return formatRelativeTime(date)
}
function compareRows(a: ServerPlayerRow, b: ServerPlayerRow) {
let compared: number
if (sortColumn.value === 'username') compared = a.username.localeCompare(b.username)
else if (sortColumn.value === 'lastPlayed') {
compared =
(a.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY) -
(b.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY)
} else if (sortColumn.value === 'method') {
compared = methodLabel(a.method).localeCompare(methodLabel(b.method))
} else {
compared =
(a.pending ? Number.MAX_SAFE_INTEGER : (a.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) -
(b.pending ? Number.MAX_SAFE_INTEGER : (b.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY))
}
return sortDirection.value === 'asc' ? compared : -compared
}
function handleSort(column: string, direction: SortDirection) {
sortColumn.value = column
sortDirection.value = direction
}
</script>
@@ -0,0 +1,245 @@
<template>
<div class="flex flex-col gap-6">
<ServerPlayCard
:address="serverAddress"
:modpack-download-url="modpackDownload?.url"
:modpack-filename="modpackDownload?.filename"
@play="props.onPlayServer?.(serverAddress)"
@invite="showInvitePlayers"
/>
<section class="flex flex-col gap-3">
<h1 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.invitedPlayersTitle) }}
</h1>
<ServerPlayersTable
:rows="playersToDisplay"
@remove="removePlayer"
@open-actions="(player) => props.onOpenPlayerActions?.(player)"
/>
</section>
<InvitePlayersModal
ref="invitePlayersModal"
:header="invitePlayersHeader"
:friends="dummyFriends"
:search-users="searchInviteUsers"
:link="dummyInviteLink"
:link-expires-at="dummyInviteLinkExpiresAt"
:link-max-uses="10"
@invite="invitePlayer"
@cancel="cancelPlayerInvite"
/>
</div>
</template>
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import {
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersSearchUser,
type InvitePlayersUser,
} from '#ui/components/sharing'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import ServerPlayCard from './ServerPlayCard.vue'
import ServerPlayersTable from './ServerPlayersTable.vue'
import type { ServerPlayerRow } from './types'
const props = defineProps<{
players?: ServerPlayerRow[]
onPlayServer?: (address: string) => void | Promise<void>
onInvitePlayers?: () => void | Promise<void>
onRemovePlayer?: (player: ServerPlayerRow) => void | Promise<void>
onOpenPlayerActions?: (player: ServerPlayerRow) => void | Promise<void>
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { server, serverFull } = injectModrinthServerContext()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal> | null>(null)
const dummyPlayers = ref<ServerPlayerRow[]>(createDummyPlayers())
const dummyFriends = ref<InvitePlayersUser[]>(createDummyFriends())
const dummySearchUsers = createDummySearchUsers()
const dummyInviteLinkExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const dummyInviteLink = 'https://modrinth.com/servers/invite/demo'
const messages = defineMessages({
invitedPlayersTitle: {
id: 'servers.play.players.title',
defaultMessage: 'Invited players',
},
})
const playersToDisplay = computed(() => props.players ?? dummyPlayers.value)
const invitePlayersHeader = computed(() => `Invite players to ${server.value.name}`)
const serverAddress = computed(() => {
const subdomain = server.value.net.domain || serverFull.value?.subdomain
if (subdomain) {
return subdomain.endsWith('.modrinth.gg') ? subdomain : `${subdomain}.modrinth.gg`
}
return server.value.net.ip || ''
})
const modpackVersionId = computed(() =>
server.value.upstream?.kind === 'modpack' ? server.value.upstream.version_id : null,
)
const { data: modpackVersion } = useQuery({
queryKey: computed(() => ['servers', 'play', 'modpack-version', modpackVersionId.value]),
queryFn: () => client.labrinth.versions_v3.getVersion(modpackVersionId.value!),
enabled: computed(() => Boolean(modpackVersionId.value)),
staleTime: 5 * 60 * 1000,
})
const modpackDownload = computed(() => {
const version = modpackVersion.value
if (!version) return undefined
return version.files.find((file) => file.primary) ?? version.files[0]
})
function showInvitePlayers() {
invitePlayersModal.value?.show()
void props.onInvitePlayers?.()
}
async function searchInviteUsers(query: string): Promise<InvitePlayersSearchUser[]> {
const normalizedQuery = query.trim().toLowerCase()
const friendKeys = new Set(
dummyFriends.value.flatMap((friend) => [
friend.id.toLowerCase(),
friend.username.toLowerCase(),
]),
)
return dummySearchUsers.filter(
(user) =>
user.username.toLowerCase().startsWith(normalizedQuery) &&
!friendKeys.has(user.id.toLowerCase()) &&
!friendKeys.has(user.username.toLowerCase()),
)
}
function invitePlayer(payload: InvitePlayersInvitePayload) {
const existingFriend = dummyFriends.value.find((friend) => friend.id === payload.user.id)
if (existingFriend) {
existingFriend.status = 'pending'
} else {
dummyFriends.value.push({
...payload.user,
status: 'pending',
})
}
const existingPlayer = dummyPlayers.value.find((player) => player.id === payload.user.id)
if (existingPlayer) {
existingPlayer.pending = true
existingPlayer.method = 'direct'
return
}
dummyPlayers.value.push({
id: payload.user.id,
username: payload.user.username,
avatarUrl: payload.user.avatarUrl ?? undefined,
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
})
}
function cancelPlayerInvite(user: InvitePlayersUser) {
const friend = dummyFriends.value.find((candidate) => candidate.id === user.id)
if (friend) friend.status = 'available'
dummyPlayers.value = dummyPlayers.value.filter(
(player) => player.id !== user.id || !player.pending,
)
}
function removePlayer(player: ServerPlayerRow) {
if (player.pending) {
cancelPlayerInvite({ id: player.id, username: player.username })
}
void props.onRemovePlayer?.(player)
}
function createDummyFriends(): InvitePlayersUser[] {
return [
{ id: 'coolbot', username: 'Coolbot', status: 'pending', online: true },
{ id: 'geometrically', username: 'Geometrically', status: 'added', online: true },
{ id: 'josh', username: 'Josh', status: 'added' },
{ id: 'prospector', username: 'Prospector', status: 'added' },
{ id: 'fetch', username: 'Fetch', status: 'available', online: true },
{ id: 'emma', username: 'Emma', status: 'available' },
]
}
function createDummySearchUsers(): InvitePlayersSearchUser[] {
return [
{ id: 'paperclip', username: 'Paperclip' },
{ id: 'steve', username: 'Steve' },
{ id: 'alex', username: 'Alex' },
]
}
function createDummyPlayers(): ServerPlayerRow[] {
const now = Date.now()
const daysAgo = (days: number) => new Date(now - days * 24 * 60 * 60 * 1000)
return [
{
id: 'coolbot',
username: 'Coolbot',
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
},
{
id: 'geometrically',
username: 'Geometrically',
lastPlayedAt: daysAgo(0),
joinedAt: daysAgo(0),
method: 'link',
},
{
id: 'josh',
username: 'Josh',
lastPlayedAt: daysAgo(0),
joinedAt: daysAgo(0),
method: 'link',
},
{
id: 'boris',
username: 'Boris',
lastPlayedAt: daysAgo(4),
joinedAt: daysAgo(7),
method: 'direct',
},
{
id: 'prospector',
username: 'Prospector',
lastPlayedAt: daysAgo(4),
joinedAt: daysAgo(30),
method: 'direct',
},
{
id: 'imb',
username: 'IMB',
lastPlayedAt: daysAgo(14),
joinedAt: daysAgo(90),
method: 'direct',
},
]
}
</script>
@@ -0,0 +1,11 @@
export type ServerPlayerMethod = 'direct' | 'link'
export type ServerPlayerRow = {
id: string
username: string
avatarUrl?: string
lastPlayedAt: Date | null
joinedAt: Date | null
method: ServerPlayerMethod
pending?: boolean
}
@@ -318,6 +318,7 @@ import {
LinkIcon,
LoaderCircleIcon,
LockIcon,
PlayIcon,
MoreVerticalIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
@@ -395,6 +396,7 @@ const props = withDefaults(
showCopyIdAction?: boolean
showAdvancedDebugInfo?: boolean
showUptime?: boolean
showPlayTab?: boolean
additionalTabs?: Tab[]
stripePublishableKey?: string
siteUrl?: string
@@ -419,6 +421,7 @@ const props = withDefaults(
showCopyIdAction: false,
showAdvancedDebugInfo: false,
showUptime: true,
showPlayTab: false,
additionalTabs: () => [],
stripePublishableKey: undefined,
siteUrl: undefined,
@@ -776,6 +779,16 @@ watch(serverData, (data) => {
})
const navLinks = computed<Tab[]>(() => [
...(props.showPlayTab
? [
{
label: 'Play',
href: `/hosting/manage/${props.serverId}/play`,
icon: PlayIcon,
subpages: [],
},
]
: []),
{
label: 'Overview',
href: `/hosting/manage/${props.serverId}`,
+1
View File
@@ -5,5 +5,6 @@ export { default as ServersManageContentPage } from './hosting/manage/content.vu
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
export { default as ServersManagePlayPage } from './hosting/manage/[id]/play/play.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
export * from './settings'