mirror of
https://github.com/modrinth/code.git
synced 2026-08-29 19:15:58 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba83bd0a8b |
@@ -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 }
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user