feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -0,0 +1,260 @@
<template>
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-6 max-w-[500px]">
<div class="flex flex-col gap-2 w-full">
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth</h2>
<p class="m-0 text-base text-secondary">
Your server is ready. Here's what you need to do to start playing!
</p>
</div>
<div class="flex flex-col gap-4">
<span class="text-base font-medium text-secondary"> Setup your server (~2mins) </span>
<div class="rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-5">
<div class="flex flex-col">
<div v-for="(step, i) in steps" :key="i" class="flex gap-3">
<div class="flex w-10 shrink-0 flex-col items-center">
<div
class="flex size-10 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4"
>
<component :is="step.icon" class="size-6" />
</div>
<div
v-if="i < steps.length - 1"
class="my-2 flex-1 w-0.5 rounded-full bg-surface-5"
/>
</div>
<div :class="['flex flex-col gap-1 pt-2', i < steps.length - 1 ? 'pb-[44px]' : '']">
<span class="text-base font-semibold text-contrast">
{{ i + 1 }}. {{ step.title }}
</span>
<span class="text-base text-secondary">
{{ step.description }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="w-full">
<ButtonStyled v-if="uploading" size="large">
<button class="ml-auto" disabled>
<SpinnerIcon class="animate-spin" />
Uploading ({{ uploadPercent }}%)
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button class="ml-auto" @click="openModal">Setup server <RightArrowIcon /></button>
</ButtonStyled>
</div>
<CreationFlowModal
ref="modalRef"
type="server-onboarding"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
@hide="() => {}"
@browse-modpacks="onBrowseModpacks"
@create="onCreate"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { GlobeIcon, PackageIcon, RightArrowIcon, SpinnerIcon, UsersIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { CreationFlowContextValue } from '#ui/components'
import { CreationFlowModal } from '#ui/components'
import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
query: query || undefined,
facets: [['project_type:modpack'], ['client_side:required'], ['server_side:required']],
limit,
})
}
async function getProjectVersions(projectId: string) {
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
return versions.map((v) => ({ id: v.id }))
}
const { serverId, worldId, server } = injectModrinthServerContext()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const modalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const uploading = ref(false)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const uploadPercent = computed(() =>
totalBytes.value > 0 ? Math.round((uploadedBytes.value / totalBytes.value) * 100) : 0,
)
const openModal = () => modalRef.value?.show()
onBeforeUnmount(() => modalRef.value?.hide())
function onBrowseModpacks() {
router.push({
path: '/discover/modpacks',
query: { sid: serverId, from: 'onboarding', wid: worldId.value },
})
}
onMounted(async () => {
if (route.query.resumeModal === 'setup-type') {
router.replace({ query: {} })
openModal()
return
}
if (route.query.resumeModal === 'modpack') {
const mpPid = route.query.mp_pid as string | undefined
const mpVid = route.query.mp_vid as string | undefined
const mpName = route.query.mp_name as string | undefined
router.replace({ query: {} })
openModal()
await nextTick()
const ctx = modalRef.value?.ctx
if (ctx && mpPid && mpVid) {
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: mpPid,
versionId: mpVid,
name: mpName ?? '',
}
ctx.modal.value?.setStage('final-config')
} else {
ctx?.setSetupType('modpack')
}
}
})
async function finalizeSetup() {
modalRef.value?.hide()
server.value.flows = { intro: false }
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
await router.push(`/hosting/manage/${serverId}/content`)
}
/** Map UI loader names to API Modloader values */
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
return loader as Archon.Content.v1.Modloader
}
const onCreate = async (config: CreationFlowContextValue) => {
// Handle mrpack file upload
if (config.setupType.value === 'modpack' && config.modpackFile.value) {
modalRef.value?.hide()
uploading.value = true
uploadedBytes.value = 0
totalBytes.value = config.modpackFile.value.size
try {
const handle = client.kyros.content_v1.uploadModpackFile(
worldId.value!,
config.modpackFile.value,
config.buildProperties(),
{
softOverride: true,
onProgress: ({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
},
},
)
await handle.promise
server.value.status = 'installing'
await finalizeSetup()
} catch {
addNotification({
title: 'Modpack upload failed',
text: 'An unexpected error occurred while uploading. Please try again later.',
type: 'error',
})
config.loading.value = false
uploading.value = false
}
return
}
let request: Archon.Content.v1.InstallWorldContent
const properties = config.buildProperties()
if (config.setupType.value === 'modpack' && config.modpackSelection.value) {
request = {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties,
}
} else {
const loader = config.selectedLoader.value
request = {
content_variant: 'bare',
loader: loader ? toApiLoader(loader) : 'vanilla',
version: config.selectedLoaderVersion.value ?? '',
game_version: config.selectedGameVersion.value ?? undefined,
soft_override: false,
properties,
}
}
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
server.value.status = 'installing'
await finalizeSetup()
} catch {
addNotification({
title: 'Installation failed',
text: 'An unexpected error occurred while installing. Please try again later.',
type: 'error',
})
config.loading.value = false
}
}
const steps = [
{
icon: PackageIcon,
title: 'Choose what to play',
description:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
},
{
icon: GlobeIcon,
title: 'Configure your world',
description: 'Set up your world just like singleplayer. Choose your gamemode and world seed.',
},
{
icon: UsersIcon,
title: 'Invite your friends',
description:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
},
]
</script>

View File

@@ -0,0 +1,434 @@
<template>
<Transition name="fade" mode="out-in">
<div
v-if="error"
key="error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ error.message }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="refetch">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else key="content" class="contents">
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
<BackupRestoreModal ref="restoreBackupModal" />
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
<span class="text-2xl font-semibold text-contrast">Backups</span>
<ButtonStyled color="brand">
<button
v-tooltip="backupCreationDisabled"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="size-5" />
Create backup
</button>
</ButtonStyled>
</div>
<div class="flex w-full flex-col gap-1.5">
<Transition name="fade" mode="out-in">
<div
v-if="groupedBackups.length === 0"
key="empty"
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="!backupsData">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else>
<EmptyState
type="empty-inbox"
heading="No backups yet"
description="Create your first backup"
>
<template #actions>
<ButtonStyled color="brand">
<button
v-tooltip="backupCreationDisabled"
:disabled="!!backupCreationDisabled"
class="w-min mx-auto"
@click="showCreateModel"
>
<PlusIcon class="size-5" />
Create backup
</button>
</ButtonStyled>
</template>
</EmptyState>
</template>
</div>
<div v-else key="list" class="flex flex-col gap-1.5">
<template v-for="group in groupedBackups" :key="group.label">
<div class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
</div>
<div class="flex gap-2">
<div class="flex w-5 justify-center">
<div class="h-full w-px bg-surface-5" />
</div>
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
<BackupItem
v-for="backup in group.backups"
:key="`backup-${backup.id}`"
:backup="backup"
:restore-disabled="backupRestoreDisabled"
:kyros-url="server.node?.instance"
:jwt="server.node?.token"
:show-debug-info="showDebugInfo"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@delete="
(skipConfirmation?: boolean) =>
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</TransitionGroup>
</div>
</template>
</div>
</Transition>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { server, worldId, backupsState, markBackupCancelled, busyReasons } =
injectModrinthServerContext()
const props = defineProps<{
isServerRunning: boolean
showDebugInfo?: boolean
}>()
const route = useRoute()
const serverId = route.params.id as string
defineEmits(['onDownload'])
const backupsQueryKey = ['backups', 'list', serverId]
const {
data: backupsData,
error,
refetch,
} = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
})
const deleteMutation = useMutation({
mutationFn: (backupId: string) =>
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
onSuccess: (_data, backupId) => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
},
})
const retryMutation = useMutation({
mutationFn: (backupId: string) =>
client.archon.backups_v1.retry(serverId, worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const backups = computed(() => {
if (!backupsData.value) return []
const merged = backupsData.value.map((backup) => {
const progressState = backupsState.get(backup.id)
if (progressState) {
const hasOngoingTask = Object.values(progressState).some((task) => task?.state === 'ongoing')
const hasCompletedTask = Object.values(progressState).some((task) => task?.state === 'done')
return {
...backup,
task: {
...backup.task,
...progressState,
},
ongoing: hasOngoingTask || (backup.ongoing && !hasCompletedTask),
}
}
return backup
})
return merged.sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
type BackupGroup = {
label: string
icon: Component | null
backups: Archon.Backups.v1.Backup[]
}
const groupedBackups = computed((): BackupGroup[] => {
if (!backups.value.length) return []
const now = dayjs()
const groups: BackupGroup[] = []
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => {
let group = groups.find((g) => g.label === label)
if (!group) {
group = { label, icon, backups: [] }
groups.push(group)
}
group.backups.push(backup)
}
for (const backup of backups.value) {
const created = dayjs(backup.created_at)
const diffMinutes = now.diff(created, 'minute')
const isToday = created.isSame(now, 'day')
const isYesterday = created.isSame(now.subtract(1, 'day'), 'day')
const diffDays = now.diff(created, 'day')
if (diffMinutes < 30 && isToday) {
addToGroup('Just now', CalendarIcon, backup)
} else if (isToday) {
addToGroup('Earlier today', CalendarIcon, backup)
} else if (isYesterday) {
addToGroup('Yesterday', CalendarIcon, backup)
} else if (diffDays <= 14) {
addToGroup('Last 2 weeks', CalendarIcon, backup)
} else {
addToGroup('Older', CalendarIcon, backup)
}
}
return groups
})
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupRestoreDisabled = computed(() => {
if (props.isServerRunning) {
return 'Cannot restore backup while server is running'
}
if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason)
}
return undefined
})
const backupCreationDisabled = computed(() => {
const quota = server.value.backup_quota
if (quota !== undefined) {
const usedCount = backupsData.value?.length ?? server.value.used_backup_quota ?? 0
if (usedCount >= quota) {
return `All ${quota} of your backup slots are in use`
}
}
if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason)
}
// also check API data for ongoing backups (before ws fires)
if (backupsData.value?.some((backup) => backup.ongoing)) {
return 'A backup is already in progress'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show()
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const retryBackup = (backupId: string) => {
retryMutation.mutate(backupId, {
onError: (err) => {
console.error('Failed to retry backup:', err)
},
})
}
function deleteBackup(backup?: Archon.Backups.v1.Backup) {
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
deleteMutation.mutate(backup.id, {
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
},
})
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.98);
}
.list-enter-active,
.list-leave-active {
transition: all 200ms ease-in-out;
}
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-move {
transition: transform 200ms ease-in-out;
}
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>

View File

@@ -0,0 +1,830 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import ConfirmLeaveModal from '../../../shared/content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
import type {
ContentModpackData,
UploadState,
} from '../../../shared/content-tab/providers/content-manager'
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
} from '../../../shared/content-tab/types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
failedToRemoveContent: {
id: 'hosting.content.failed-to-remove',
defaultMessage: 'Failed to remove content',
},
failedToToggle: {
id: 'hosting.content.failed-to-toggle',
defaultMessage: 'Failed to toggle {name}',
},
failedToUpload: {
id: 'hosting.content.failed-to-upload',
defaultMessage: 'Failed to upload file',
},
failedToUnlink: {
id: 'hosting.content.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
failedToLoadModpackContent: {
id: 'hosting.content.failed-to-load-modpack-content',
defaultMessage: 'Failed to load modpack content',
},
failedToLoadVersions: {
id: 'hosting.content.failed-to-load-versions',
defaultMessage: 'Failed to load versions',
},
failedToUpdate: {
id: 'hosting.content.failed-to-update',
defaultMessage: 'Failed to update',
},
})
const props = withDefaults(
defineProps<{
showClientOnlyFilter?: boolean
}>(),
{
showClientOnlyFilter: false,
},
)
const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const serverId = route.params.id as string
const type = computed(() => {
const loader = server.value?.loader?.toLowerCase()
if (loader === 'paper' || loader === 'purpur') return 'plugin'
if (loader === 'vanilla') return 'datapack'
return 'mod'
})
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
const contentQuery = useQuery({
queryKey,
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
})
const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null)
const modpackVersionsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpackProjectId.value]),
queryFn: () =>
client.labrinth.versions_v2.getProjectVersions(modpackProjectId.value!, {
include_changelog: false,
}),
enabled: computed(() => !!modpackProjectId.value),
})
const projectQuery = useQuery({
queryKey: computed(() => ['labrinth', 'project', modpackProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value),
})
const modpack = computed<ContentModpackData | null>(() => {
const mp = contentQuery.data.value?.modpack
if (!mp) return null
const project = projectQuery.data.value
return {
project: {
id: mp.spec.project_id,
slug: project?.slug ?? mp.spec.project_id,
title: mp.title ?? mp.spec.project_id,
icon_url: mp.icon_url ?? undefined,
description: mp.description ?? '',
downloads: mp.downloads ?? 0,
followers: mp.followers ?? 0,
} as ContentModpackCardProject,
projectLink: `/project/${project?.slug ?? mp.spec.project_id}`,
version: {
id: mp.spec.version_id,
version_number: mp.version_number ?? '',
date_published: mp.date_published ?? '',
} as ContentModpackCardVersion,
versionLink: `/project/${project?.slug ?? mp.spec.project_id}/version/${mp.spec.version_id}`,
owner: mp.owner
? {
id: mp.owner.id,
name: mp.owner.name,
avatar_url: mp.owner.icon_url ?? undefined,
type: mp.owner.type,
link:
mp.owner.type === 'organization'
? `/organization/${mp.owner.id}`
: `/user/${mp.owner.id}`,
}
: undefined,
categories: (project?.display_categories ?? []).map((name) => ({
name,
icon: name,
project_type: 'modpack',
header: 'categories',
})) as ContentModpackCardCategory[],
hasUpdate: !!mp.has_update,
}
})
function friendlyAddonName(addon: Archon.Content.v1.Addon): string {
if (addon.name) return addon.name
let cleanName = addon.filename
const lastDotIndex = cleanName.lastIndexOf('.')
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
return cleanName
}
const modpackAddons = ref<Archon.Content.v1.Addon[]>([])
const addonLookup = computed(() => {
const map = new Map<string, Archon.Content.v1.Addon>()
for (const addon of contentQuery.data.value?.addons ?? []) {
map.set(addon.filename, addon)
}
for (const addon of modpackAddons.value) {
map.set(addon.filename, addon)
}
return map
})
const contentItems = computed<ContentItem[]>(() => {
return (contentQuery.data.value?.addons ?? []).map(addonToContentItem)
})
const deleteMutation = useMutation({
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
filename: addon.filename,
kind: addon.kind,
}),
onMutate: async ({ addon }) => {
await queryClient.cancelQueries({ queryKey: queryKey.value })
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(queryKey.value)
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
if (!oldData) return oldData
return {
...oldData,
addons: (oldData.addons ?? []).filter((a) => a.filename !== addon.filename),
}
})
return { previousData }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
},
onError: (err, _vars, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey.value, context.previousData)
}
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRemoveContent),
})
},
})
const toggleMutation = useMutation({
mutationFn: async ({ addon }: { addon: Archon.Content.v1.Addon }) => {
const request: Archon.Content.v1.RemoveAddonRequest = {
filename: addon.filename,
kind: addon.kind,
}
if (addon.disabled) {
await client.archon.content_v1.enableAddon(serverId, worldId.value!, request)
} else {
await client.archon.content_v1.disableAddon(serverId, worldId.value!, request)
}
return { filename: addon.filename, newDisabled: !addon.disabled }
},
onSuccess: ({ filename, newDisabled }) => {
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
if (!oldData) return oldData
return {
...oldData,
addons: (oldData.addons ?? []).map((a) =>
a.filename === filename ? { ...a, disabled: newDisabled } : a,
),
}
})
queryClient.invalidateQueries({ queryKey: queryKey.value })
},
onError: (_err, { addon }) => {
addNotification({
type: 'error',
text: formatMessage(messages.failedToToggle, { name: friendlyAddonName(addon) }),
})
},
})
async function handleToggleEnabled(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await toggleMutation.mutateAsync({ addon })
}
async function handleDeleteItem(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await deleteMutation.mutateAsync({ addon })
}
function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAddonRequest[] {
return items.flatMap((item) => {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return []
return [{ filename: addon.filename, kind: addon.kind }]
})
}
async function handleBulkDelete(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.deleteAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleBulkEnable(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleBulkDisable(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
let activeUploadCancel: (() => void) | null = null
const isUploading = computed(() => uploadState.value.isUploading)
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isUploading.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(isUploading, (uploading) => {
if (uploading) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (isUploading.value) {
const shouldLeave = (await confirmLeaveModal.value?.prompt()) ?? false
if (shouldLeave) {
activeUploadCancel?.()
}
return shouldLeave
}
return true
})
}
const updatingProject = ref<ContentItem | null>(null)
const updatingModpack = ref(false)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const currentGameVersion = computed(() => contentQuery.data.value?.game_version ?? '')
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
function handleBrowseContent() {
router.push({
path: `/discover/${type.value}s`,
query: { sid: serverId, wid: worldId.value },
})
}
function handleUploadFiles() {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
input.accept = type.value === 'datapack' ? '.zip' : '.jar'
input.onchange = async () => {
if (!input.files) return
const files = Array.from(input.files)
const wid = worldId.value
if (!wid) return
uploadState.value = {
isUploading: true,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
completedFiles: 0,
totalFiles: files.length,
}
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
onProgress: (p) => {
uploadState.value.currentFileProgress = p.progress
uploadState.value.uploadedBytes = p.loaded
uploadState.value.totalBytes = p.total
},
})
activeUploadCancel = () => handle.cancel()
try {
await handle.promise
uploadState.value.completedFiles = files.length
await contentQuery.refetch()
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') return
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpload),
})
} finally {
activeUploadCancel = null
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
}
input.click()
}
function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
return {
project: {
id: addon.project_id ?? addon.filename,
slug: addon.project_id ?? addon.filename,
title: friendlyAddonName(addon),
icon_url: addon.icon_url ?? undefined,
},
version: {
id: addon.version?.id ?? addon.filename,
version_number: addon.version?.name ?? formatMessage(commonMessages.unknownLabel),
file_name: addon.filename,
},
owner: addon.owner
? {
id: addon.owner.id,
name: addon.owner.name,
type: addon.owner.type,
avatar_url: addon.owner.icon_url ?? undefined,
link: `/${addon.owner.type}/${addon.owner.id}`,
}
: undefined,
enabled: !addon.disabled,
file_name: addon.filename,
project_type: addon.kind,
has_update: !!addon.has_update,
update_version_id: addon.has_update,
environment: addon.version?.environment ?? undefined,
}
}
async function handleViewModpackContent() {
modpackContentModal.value?.showLoading()
try {
const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
from_modpack: true,
})
modpackAddons.value = data.addons ?? []
const items = (data.addons ?? []).map(addonToContentItem)
modpackContentModal.value?.show(items)
} catch (err) {
modpackContentModal.value?.hide()
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadModpackContent),
})
}
}
async function handleModpackContentToggle(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
try {
await toggleMutation.mutateAsync({ addon })
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
)
modpackContentModal.value?.updateItem(item.file_name, {
enabled: !item.enabled,
disabled: false,
})
} catch {
modpackContentModal.value?.updateItem(item.file_name, { disabled: false })
}
}
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
// Optimistic update
for (const item of items) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === item.file_name ? { ...a, disabled: !enable } : a,
)
modpackContentModal.value?.updateItem(item.file_name, { enabled: enable })
}
try {
if (enable) {
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
} else {
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
}
await queryClient.invalidateQueries({ queryKey: queryKey.value })
} catch {
// Revert
for (const item of items) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === item.file_name ? { ...a, disabled: enable } : a,
)
modpackContentModal.value?.updateItem(item.file_name, { enabled: !enable })
}
}
}
function handleModpackUnlink() {
modpackUnlinkModal.value?.show()
}
async function handleModpackUnlinkConfirm() {
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
await contentQuery.refetch()
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
})
}
}
async function handleBulkUpdate(items: ContentItem[]) {
const addons = items
.filter((item) => item.has_update)
.map((item) => ({
filename: item.file_name,
version_id: item.update_version_id ?? undefined,
}))
if (addons.length === 0) return
await client.archon.content_v1.updateAddons(serverId, worldId.value!, addons)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleUpdateItem(fileNameKey: string) {
const item = contentItems.value.find((i) => i.file_name === fileNameKey)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
loadingVersions.value = true
loadingChangelog.value = false
await nextTick()
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
include_changelog: false,
})
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
} finally {
loadingVersions.value = false
}
}
async function handleModpackUpdate() {
const mp = contentQuery.data.value?.modpack
if (!mp?.spec.project_id) return
updatingModpack.value = true
updatingProject.value = null
loadingChangelog.value = false
const cached = modpackVersionsQuery.data.value
if (cached) {
const sorted = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = sorted
loadingVersions.value = false
} else {
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
contentUpdaterModal.value?.show(mp.spec.version_id ?? undefined)
if (!cached) {
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(mp.spec.project_id, {
include_changelog: false,
})
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
} finally {
loadingVersions.value = false
}
}
}
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
loadingChangelog.value = true
try {
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
if (index !== -1) {
const newVersions = [...updatingProjectVersions.value]
newVersions[index] = fullVersion
updatingProjectVersions.value = newVersions
}
} catch {
// Silently fail on changelog fetch
} finally {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
try {
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
if (index !== -1) {
const newVersions = [...updatingProjectVersions.value]
newVersions[index] = fullVersion
updatingProjectVersions.value = newVersions
}
} catch {
// Silently fail on hover prefetch
}
}
function resetUpdateState() {
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
}
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
if (updatingModpack.value) {
const currentVersionId = contentQuery.data.value?.modpack?.spec.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
pendingModpackUpdateVersion.value = selectedVersion
modpackUpdateModal.value?.show()
return
}
performUpdate(selectedVersion)
}
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
try {
if (updatingModpack.value) {
const mp = contentQuery.data.value?.modpack
if (!mp) return
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: mp.spec.project_id,
version_id: selectedVersion.id,
},
soft_override: true,
})
} else if (updatingProject.value) {
const addon = addonLookup.value.get(updatingProject.value.file_name)
if (addon) {
await client.archon.content_v1.updateAddon(serverId, worldId.value!, {
filename: addon.filename,
version_id: selectedVersion.id,
})
}
}
await contentQuery.refetch()
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpdate),
})
} finally {
resetUpdateState()
}
}
function handleModpackUpdateConfirm() {
if (pendingModpackUpdateVersion.value) {
performUpdate(pendingModpackUpdateVersion.value)
pendingModpackUpdateVersion.value = null
}
}
function handleModpackUpdateCancel() {
pendingModpackUpdateVersion.value = null
}
provideContentManager({
items: contentItems,
loading: computed(() => contentQuery.isLoading.value),
error: computed(() => contentQuery.error.value ?? null),
modpack,
isPackLocked: ref(false),
isBusy: computed(() => busyReasons.value.length > 0),
busyMessage: computed(() => {
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
const nonBannerReasons = bannerCoversInstalling
? busyReasons.value.filter(
(r) =>
r.reason.id !== 'servers.busy.installing' &&
r.reason.id !== 'servers.busy.syncing-content',
)
: busyReasons.value
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null
}),
getItemId: (item) => item.file_name,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
deleteItem: handleDeleteItem,
bulkDeleteItems: handleBulkDelete,
bulkEnableItems: handleBulkEnable,
bulkDisableItems: handleBulkDisable,
refresh: async () => {
await contentQuery.refetch()
},
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
uploadState,
showClientOnlyFilter: props.showClientOnlyFilter,
deletionContext: 'server',
hasUpdateSupport: true,
updateItem: handleUpdateItem,
bulkUpdateItems: handleBulkUpdate,
updateModpack: handleModpackUpdate,
viewModpackContent: handleViewModpackContent,
unlinkModpack: handleModpackUnlink,
openSettings: () => router.push(`/hosting/manage/${serverId}/options/loader`),
mapToTableItem: (item) => {
const projectType = item.project_type ?? type.value
return {
id: item.file_name,
project: item.project,
projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined,
version: item.version,
versionLink:
item.project?.id && item.version?.id
? `/${projectType}/${item.project.id}/version/${item.version.id}`
: undefined,
owner: item.owner
? { ...item.owner, link: `/${item.owner.type}/${item.owner.id}` }
: undefined,
enabled: item.enabled,
}
},
})
</script>
<template>
<ContentPageLayout>
<template #modals>
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="modpack?.project.title"
:modpack-icon-url="modpack?.project.icon_url"
enable-toggle
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackBulkToggle($event, true)"
@bulk:disable="handleModpackBulkToggle($event, false)"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="currentGameVersion"
:current-loader="currentLoader"
:current-version-id="
updatingModpack
? (contentQuery.data.value?.modpack?.spec.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="false"
:is-modpack="updatingModpack"
:project-icon-url="
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isModpackUpdateDowngrade"
server
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ConfirmLeaveModal ref="confirmLeaveModal" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<ServersUpgradeModalWrapper
v-if="isNuxt"
ref="upgradeModal"
:stripe-publishable-key
:site-url
:products
/>
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Hosting server, it is currently in a queue and
will appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com"
>Contact Modrinth Support</AutoLink
>
</ButtonStyled>
<ButtonStyled size="large" @click="() => router.go(0)">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<Transition v-else name="fade" mode="out-in">
<div v-if="isLoading && !serverResponse" key="loading" class="flex flex-col gap-4 py-8">
<div class="mb-4 text-center">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
<p class="m-0 mt-2 text-secondary">Loading your servers...</p>
</div>
<div
v-for="i in 3"
:key="i"
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
>
<div class="size-16 rounded-xl bg-button-bg"></div>
<div class="flex flex-1 flex-col gap-2">
<div class="h-6 w-48 rounded bg-button-bg"></div>
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
</div>
</div>
</div>
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8"
>
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="
mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%);
"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<AutoLink to="/servers#plan">Create a server</AutoLink>
</ButtonStyled>
</div>
<div v-else key="list">
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<StyledInput
id="search"
v-model="searchInput"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
wrapper-class="w-full md:w-72"
/>
<ButtonStyled v-if="isNuxt" type="standard">
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
<PlusIcon />
New server
</AutoLink>
</ButtonStyled>
</div>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="isPollingForNewServers"
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
>
<LoaderCircleIcon class="size-4 animate-spin" />
<span>Checking for new servers...</span>
</div>
</Transition>
<TransitionGroup
v-if="filteredData.length > 0 || isPollingForNewServers"
name="list"
tag="ul"
class="m-0 flex flex-col gap-4 p-0"
>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openUpgradeModal(server.server_id)"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
/>
</TransitionGroup>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { type Archon, type Labrinth, NuxtModrinthClient } from '@modrinth/api-client'
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { AutoLink, ButtonStyled, CopyCode, injectModrinthClient, StyledInput } from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
defineProps<{
stripePublishableKey?: string
siteUrl?: string
products?: Labrinth.Billing.Internal.Product[]
}>()
const router = useRouter()
const route = useRoute()
const client = injectModrinthClient()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const pollingState = ref({
enabled: false,
count: 0,
initialServers: [] as Archon.Servers.v0.Server[],
})
const {
data: serverResponse,
error: fetchError,
isLoading,
} = useQuery({
queryKey: ['servers'],
queryFn: async () => {
const response = await client.archon.servers_v0.list()
// Fetch subscriptions for medal servers
const hasMedalServers = response.servers.some((s) => s.is_medal)
if (hasMedalServers) {
const subscriptions = await client.labrinth.billing_internal.getSubscriptions()
// Inject medal_expires into servers
for (const server of response.servers) {
if (server.is_medal) {
const sub = subscriptions.find((s) => s.metadata?.id === server.server_id)
if (sub) {
server.medal_expires = dayjs(sub.created).add(5, 'days').toISOString()
}
}
}
}
// Check if new servers appeared (stop polling)
if (pollingState.value.enabled) {
pollingState.value.count++
if (response.servers.length !== pollingState.value.initialServers.length) {
pollingState.value.enabled = false
isPollingForNewServers.value = false
router.replace({ query: {} })
} else if (pollingState.value.count >= 5) {
pollingState.value.enabled = false
isPollingForNewServers.value = false
}
}
return response
},
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
})
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response
})
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
if (!serverResponse.value) return []
return serverResponse.value.servers
})
const searchInput = ref('')
const fuse = computed(() => {
if (serverList.value.length === 0) return null
return new Fuse(serverList.value, {
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
includeScore: true,
threshold: 0.4,
})
})
function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro)
})
}
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value)
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: []
})
// Start polling only after initial data is available so the baseline is correct
watch(serverResponse, (response) => {
if (
route.query.redirect_status === 'succeeded' &&
response &&
!pollingState.value.enabled &&
pollingState.value.count === 0
) {
isPollingForNewServers.value = true
pollingState.value = {
enabled: true,
count: 0,
initialServers: [...response.servers],
}
}
})
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
open: (id: string) => void | Promise<void>
}>
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
function openUpgradeModal(serverId: string) {
upgradeModal.value?.open(serverId)
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.98);
}
.list-enter-active,
.list-leave-active {
transition: all 200ms ease-in-out;
}
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-move {
transition: transform 200ms ease-in-out;
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
export { default as ServersManageContentPage } from './hosting/manage/content.vue'
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'