feat: shared loading state + cleanup loading state management (#5835)

* feat: implement shared loading bar component and polished loading states across the app

* feat: align loading states + ensureQueryData changes

* fix: lint + bugs

* fix: skeleton for manage servers page

* fix: merge conflict fix
This commit is contained in:
Calum H.
2026-04-18 19:46:39 +01:00
committed by GitHub
parent 3e32901737
commit 176d4301c3
47 changed files with 2063 additions and 1371 deletions

View File

@@ -15,7 +15,6 @@ import {
RefreshCwIcon,
SearchIcon,
ShareIcon,
SpinnerIcon,
TextCursorInputIcon,
TrashIcon,
UploadIcon,
@@ -504,304 +503,300 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
<template>
<div class="flex flex-col gap-4 pb-6">
<div
v-if="ctx.loading.value"
role="status"
aria-live="polite"
class="flex min-h-[50vh] w-full flex-col items-center justify-center gap-2 text-center text-secondary"
>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.loadingContent) }}
</div>
<div
v-else-if="ctx.error.value"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="universal-card flex flex-col items-center gap-4 p-6">
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
<p class="text-secondary">{{ ctx.error.value.message }}</p>
<ButtonStyled color="brand">
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
</ButtonStyled>
</div>
</div>
<template v-else>
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
<template #header>{{ ctx.busyMessage.value }}</template>
{{ formatMessage(messages.busyDescription) }}
</Admonition>
<ContentModpackCard
v-if="ctx.modpack.value"
:project="ctx.modpack.value.project"
:project-link="ctx.modpack.value.projectLink"
:version="ctx.modpack.value.version"
:version-link="ctx.modpack.value.versionLink"
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
aria-live="polite"
<template v-if="!ctx.loading.value">
<div
v-if="ctx.error.value"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
<div class="universal-card flex flex-col items-center gap-4 p-6">
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
<p class="text-secondary">{{ ctx.error.value.message }}</p>
<ButtonStyled color="brand">
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
</ButtonStyled>
</div>
</div>
<template v-else>
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
<template #header>{{ ctx.busyMessage.value }}</template>
{{ formatMessage(messages.busyDescription) }}
</Admonition>
<ContentModpackCard
v-if="ctx.modpack.value"
:project="ctx.modpack.value.project"
:project-link="ctx.modpack.value.projectLink"
:version="ctx.modpack.value.version"
:version-link="ctx.modpack.value.versionLink"
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
aria-live="polite"
>
<Admonition
v-if="ctx.uploadState?.value?.isUploading"
type="info"
show-actions-underneath
>
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
formatMessage(messages.uploadingFiles, {
completed: ctx.uploadState?.value?.completedFiles ?? 0,
total: ctx.uploadState?.value?.totalFiles ?? 0,
})
}}
</template>
<span class="text-secondary">
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template #actions>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.additionalContent) }}
</span>
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="text"
autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: tableItems.length,
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
})
"
/>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="@container flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.length === 0
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.length === 0"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.includes(option.id)
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
<div class="hidden @[900px]:block">
<ButtonStyled type="transparent">
<button
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</button>
</ButtonStyled>
</div>
</div>
<div class="flex items-center gap-2">
<div class="@[900px]:hidden">
<ButtonStyled type="transparent">
<button
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</button>
</ButtonStyled>
</div>
<ButtonStyled
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
color="green"
type="transparent"
color-fill="text"
hover-color-fill="background"
>
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
</div>
<ContentCardTable
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="true"
@update:enabled="handleToggleEnabledById"
@delete="handleDeleteById"
@update="handleUpdateById"
@switch-version="handleSwitchVersionById"
>
<template #empty>
<span>{{ formatMessage(messages.noContentFound) }}</span>
</template>
</ContentCardTable>
</div>
</template>
<EmptyState v-else type="empty-inbox">
<template #heading>
{{
formatMessage(messages.uploadingFiles, {
completed: ctx.uploadState?.value?.completedFiles ?? 0,
total: ctx.uploadState?.value?.totalFiles ?? 0,
})
formatMessage(
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
)
}}
</template>
<span class="text-secondary">
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template #actions>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
<template #description>
{{
ctx.modpack.value
? formatMessage(messages.emptyModpackHint)
: formatMessage(messages.emptyHint, {
contentType: `${ctx.contentTypeLabel.value}s`,
})
}}
</template>
</Admonition>
</Transition>
<template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.additionalContent) }}
</span>
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="text"
autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: tableItems.length,
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
})
"
/>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="@container flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<template #actions>
<ButtonStyled type="outlined">
<button
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.length === 0
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:aria-pressed="selectedFilters.length === 0"
@click="selectedFilters = []"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
{{ formatMessage(commonMessages.allProjectType) }}
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-for="option in filterOptions"
:key="option.id"
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.includes(option.id)
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
{{ option.label }}
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
<div class="hidden @[900px]:block">
<ButtonStyled type="transparent">
<button
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</button>
</ButtonStyled>
</div>
</div>
<div class="flex items-center gap-2">
<div class="@[900px]:hidden">
<ButtonStyled type="transparent">
<button
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</button>
</ButtonStyled>
</div>
<ButtonStyled
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
color="green"
type="transparent"
color-fill="text"
hover-color-fill="background"
>
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
</div>
<ContentCardTable
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="true"
@update:enabled="handleToggleEnabledById"
@delete="handleDeleteById"
@update="handleUpdateById"
@switch-version="handleSwitchVersionById"
>
<template #empty>
<span>{{ formatMessage(messages.noContentFound) }}</span>
</template>
</ContentCardTable>
</div>
</ButtonStyled>
</template>
</EmptyState>
</template>
<EmptyState v-else type="empty-inbox">
<template #heading>
{{
formatMessage(
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
)
}}
</template>
<template #description>
{{
ctx.modpack.value
? formatMessage(messages.emptyModpackHint)
: formatMessage(messages.emptyHint, {
contentType: `${ctx.contentTypeLabel.value}s`,
})
}}
</template>
<template #actions>
<ButtonStyled type="outlined">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
</template>
</EmptyState>
</template>
<ContentSelectionBar

View File

@@ -31,180 +31,169 @@
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
>
</FileContextMenu>
<Transition name="fade" mode="out-in">
<div
v-if="ctx.loading.value && items.length === 0"
key="loading"
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.loadingFiles) }}
</div>
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
<template #header>{{ ctx.busyWarning.value }}</template>
{{ formatMessage(messages.busyWarning) }}
</Admonition>
<div class="relative flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-4">
<FileNavbar
:breadcrumbs="breadcrumbSegments"
:is-editing="isEditing"
:editing-file-name="ctx.editingFile.value?.name"
:editing-file-path="ctx.editingFile.value?.path"
:is-editing-image="fileEditorRef?.isEditingImage"
:is-editor-find-open="fileEditorRef?.isFindOpen"
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:show-install-from-url="ctx.showInstallFromUrl"
:base-id="baseId"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@prefetch-home="handlePrefetchHome"
@update:search-query="searchQuery = $event"
@create="showCreateModal"
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@refresh="ctx.refresh"
@share="() => fileEditorRef?.shareToMclogs()"
@find="() => fileEditorRef?.toggleFind()"
/>
<div v-else key="content" class="contents">
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
<template #header>{{ ctx.busyWarning.value }}</template>
{{ formatMessage(messages.busyWarning) }}
</Admonition>
<div class="relative flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-4">
<FileNavbar
:breadcrumbs="breadcrumbSegments"
:is-editing="isEditing"
:editing-file-name="ctx.editingFile.value?.name"
:editing-file-path="ctx.editingFile.value?.path"
:is-editing-image="fileEditorRef?.isEditingImage"
:is-editor-find-open="fileEditorRef?.isFindOpen"
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:show-install-from-url="ctx.showInstallFromUrl"
:base-id="baseId"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@prefetch-home="handlePrefetchHome"
@update:search-query="searchQuery = $event"
@create="showCreateModal"
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@refresh="ctx.refresh"
@share="() => fileEditorRef?.shareToMclogs()"
@find="() => fileEditorRef?.toggleFind()"
/>
<div v-if="!isEditing">
<FileUploadDragAndDrop
ref="fileUploadRef"
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
@files-dropped="handleDroppedFiles"
>
<FileTableHeader
:sort-field="sortField"
:sort-desc="sortDescValue"
:all-selected="allSelected"
:some-selected="someSelected"
:is-stuck="isLabelBarStuck"
@sort="handleSort"
@toggle-all="toggleSelectAll"
/>
<div
v-if="filteredItems.length > 0"
ref="virtualListContainer"
class="relative w-full"
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
<FileTableRow
v-for="(item, idx) in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
:index="visibleRange.start + idx"
:is-last="visibleRange.start + idx === filteredItems.length - 1"
:selected="selectedItems.has(item.path)"
:write-disabled="isBusy"
:write-disabled-tooltip="busyTooltip"
@extract="() => handleExtractItem(item)"
@delete="() => showDeleteModal(item)"
@rename="() => showRenameModal(item)"
@download="() => handleDownload(item)"
@move="() => showMoveModal(item)"
@move-direct-to="handleDirectMove"
@edit="() => handleEditFile(item)"
@navigate="() => handleNavigateToFolder(item)"
@hover="() => handleItemHover(item)"
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
@toggle-select="() => toggleItemSelection(item.path)"
/>
</div>
</div>
<div
v-else-if="items.length === 0 && !ctx.error.value"
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
>
<div class="flex flex-col items-center gap-4 text-center">
<FolderOpenIcon class="h-16 w-16 text-secondary" />
<h3 class="m-0 text-2xl font-bold text-contrast">
{{ formatMessage(messages.emptyFolderTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.emptyFolderDescription) }}
</p>
</div>
</div>
<FileManagerError
v-else-if="ctx.error.value"
class="rounded-b-[20px]"
:title="formatMessage(messages.errorTitle)"
:message="formatMessage(messages.errorMessage)"
@refetch="ctx.refresh"
@home="navigateToSegment(-1)"
/>
</FileUploadDragAndDrop>
</div>
<FileEditor
v-else
ref="fileEditorRef"
:file="ctx.editingFile.value"
:editor-component="editorComponent"
@close="handleEditorClose"
/>
</div>
</div>
<FloatingActionBar :shown="hasUnsavedChanges">
<p class="m-0 text-sm font-semibold md:text-base">
{{ formatMessage(messages.unsavedChanges) }}
</p>
<div class="ml-auto flex gap-2">
<ButtonStyled type="transparent">
<button @click="fileEditorRef?.revertChanges()">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="fileEditorRef?.saveFileContent(false)">
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
<FloatingActionBar :shown="selectedItems.size > 0">
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button class="!text-primary" @click="deselectAll">
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</button>
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled
type="transparent"
color="red"
color-fill="text"
hover-color-fill="background"
<div v-if="!isEditing">
<FileUploadDragAndDrop
ref="fileUploadRef"
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
@files-dropped="handleDroppedFiles"
>
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</button>
</ButtonStyled>
<FileTableHeader
:sort-field="sortField"
:sort-desc="sortDescValue"
:all-selected="allSelected"
:some-selected="someSelected"
:is-stuck="isLabelBarStuck"
@sort="handleSort"
@toggle-all="toggleSelectAll"
/>
<div
v-if="filteredItems.length > 0"
ref="virtualListContainer"
class="relative w-full"
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
<FileTableRow
v-for="(item, idx) in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
:index="visibleRange.start + idx"
:is-last="visibleRange.start + idx === filteredItems.length - 1"
:selected="selectedItems.has(item.path)"
:write-disabled="isBusy"
:write-disabled-tooltip="busyTooltip"
@extract="() => handleExtractItem(item)"
@delete="() => showDeleteModal(item)"
@rename="() => showRenameModal(item)"
@download="() => handleDownload(item)"
@move="() => showMoveModal(item)"
@move-direct-to="handleDirectMove"
@edit="() => handleEditFile(item)"
@navigate="() => handleNavigateToFolder(item)"
@hover="() => handleItemHover(item)"
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
@toggle-select="() => toggleItemSelection(item.path)"
/>
</div>
</div>
<div
v-else-if="items.length === 0 && !ctx.error.value"
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
>
<div class="flex flex-col items-center gap-4 text-center">
<FolderOpenIcon class="h-16 w-16 text-secondary" />
<h3 class="m-0 text-2xl font-bold text-contrast">
{{ formatMessage(messages.emptyFolderTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.emptyFolderDescription) }}
</p>
</div>
</div>
<FileManagerError
v-else-if="ctx.error.value"
class="rounded-b-[20px]"
:title="formatMessage(messages.errorTitle)"
:message="formatMessage(messages.errorMessage)"
@refetch="ctx.refresh"
@home="navigateToSegment(-1)"
/>
</FileUploadDragAndDrop>
</div>
</FloatingActionBar>
<FileEditor
v-else
ref="fileEditorRef"
:file="ctx.editingFile.value"
:editor-component="editorComponent"
@close="handleEditorClose"
/>
</div>
</div>
</Transition>
<FloatingActionBar :shown="hasUnsavedChanges">
<p class="m-0 text-sm font-semibold md:text-base">
{{ formatMessage(messages.unsavedChanges) }}
</p>
<div class="ml-auto flex gap-2">
<ButtonStyled type="transparent">
<button @click="fileEditorRef?.revertChanges()">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="fileEditorRef?.saveFileContent(false)">
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
<FloatingActionBar :shown="selectedItems.size > 0">
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button class="!text-primary" @click="deselectAll">
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</button>
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled
type="transparent"
color="red"
color-fill="text"
hover-color-fill="background"
>
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</div>
</template>
<script setup lang="ts">
@@ -216,7 +205,6 @@ import {
PackageOpenIcon,
RightArrowIcon,
SaveIcon,
SpinnerIcon,
TrashIcon,
} from '@modrinth/assets'
import type { Component } from 'vue'
@@ -256,10 +244,6 @@ import type { FileContextMenuOption, FileItem } from './types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
loadingFiles: {
id: 'files.layout.loading',
defaultMessage: 'Loading files...',
},
busyWarning: {
id: 'files.layout.busy-warning',
defaultMessage: 'File operations are disabled while the operation is in progress.',

View File

@@ -27,122 +27,122 @@
</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" />
<ReadyTransition :pending="backupsReadyPending">
<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 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 v-if="backupsData">
<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 #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>
<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>
</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-copy-id-action="showCopyIdAction"
: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>
</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-copy-id-action="showCopyIdAction"
: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>
</Transition>
</div>
</Transition>
</div>
</template>
<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
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>
</ReadyTransition>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import type { Component } from 'vue'
@@ -151,11 +151,13 @@ import { useRoute } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import ReadyTransition from '#ui/components/base/ReadyTransition.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 { useReadyState } from '#ui/composables'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -184,13 +186,17 @@ defineEmits(['onDownload'])
const backupsQueryKey = ['backups', 'list', serverId]
const {
data: backupsData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const backupsReadyPending = useReadyState({ isLoading, data: backupsData })
const deleteMutation = useMutation({
mutationFn: (backupId: string) =>
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),

View File

@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { useReadyState } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -121,6 +123,8 @@ const contentQuery = useQuery({
staleTime: 0,
})
const contentReadyPending = useReadyState(contentQuery)
const modpackProjectId = computed(() => {
const spec = contentQuery.data.value?.modpack?.spec
return spec?.platform === 'modrinth' ? spec.project_id : null
@@ -906,50 +910,52 @@ provideContentManager({
</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.platform === 'modrinth'
? contentQuery.data.value.modpack.spec.version_id
: ''
: (updatingProject?.version?.id ?? '')
"
:is-app="false"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
: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>
<ReadyTransition :pending="contentReadyPending">
<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.platform === 'modrinth'
? contentQuery.data.value.modpack.spec.version_id
: ''
: (updatingProject?.version?.id ?? '')
"
:is-app="false"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
: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>
</ReadyTransition>
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isModpackUpdateDowngrade"

View File

@@ -4,6 +4,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useReadyState } from '#ui/composables'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -113,6 +115,8 @@ const {
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
// Prefetching
function prefetchDirectory(path: string) {
queryClient.prefetchQuery({
@@ -473,8 +477,10 @@ provideFileManager({
</script>
<template>
<FilePageLayout
:show-debug-info="props.showDebugInfo"
:show-refresh-button="props.showRefreshButton"
/>
<ReadyTransition :pending="filesReadyPending">
<FilePageLayout
:show-debug-info="props.showDebugInfo"
:show-refresh-button="props.showRefreshButton"
/>
</ReadyTransition>
</template>

View File

@@ -80,115 +80,105 @@
</div>
</div>
<Transition v-else name="fade" mode="out-in">
<template v-else>
<div
v-if="(isLoading || !authReady) && !serverResponse"
key="loading"
class="flex flex-col gap-4 py-8"
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
>
<div class="mb-4 text-center">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
<p class="m-0 mt-2 text-secondary">{{ formatMessage(messages.loadingServers) }}</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>
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
{{ formatMessage(messages.serversTitle) }}
</h1>
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
<StyledInput
id="search"
v-model="searchInput"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
:disabled="showServersListLoading"
:placeholder="formatMessage(messages.searchPlaceholder, { count: filteredData.length })"
wrapper-class="w-full md:w-72"
/>
<ButtonStyled type="standard" color="brand">
<button @click="openPurchaseModal">
<PlusIcon />
{{ formatMessage(messages.newServerButton) }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
>
<ServerListEmpty
:logged-in="loggedIn"
@click-new-server="openPurchaseModal"
@click-sign-in="handleSignIn"
/>
</div>
<div v-else key="list">
<div
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
>
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
{{ formatMessage(messages.serversTitle) }}
</h1>
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
<StyledInput
id="search"
v-model="searchInput"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
:placeholder="
formatMessage(messages.searchPlaceholder, { count: filteredData.length })
"
wrapper-class="w-full md:w-72"
/>
<ButtonStyled type="standard" color="brand">
<button @click="openPurchaseModal">
<PlusIcon />
{{ formatMessage(messages.newServerButton) }}
</button>
</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"
>
<Transition name="fade" mode="out-in">
<div v-if="showServersListLoading" key="loading" class="flex flex-col gap-3">
<div
v-if="showPollingForNewServers"
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
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"
>
<LoaderCircleIcon class="size-4 animate-spin" />
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
<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>
</Transition>
<TransitionGroup
v-if="filteredData.length > 0 || isPollingForNewServers"
name="list"
tag="ul"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openPurchaseModal"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
</div>
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
</div>
</Transition>
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
>
<ServerListEmpty
:logged-in="loggedIn"
@click-new-server="openPurchaseModal"
@click-sign-in="handleSignIn"
/>
</div>
<div v-else key="list">
<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="showPollingForNewServers"
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>{{ formatMessage(messages.checkingForNewServers) }}</span>
</div>
</Transition>
<TransitionGroup
v-if="filteredData.length > 0 || isPollingForNewServers"
name="list"
tag="ul"
class="m-0 flex flex-col gap-3 p-0"
>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openPurchaseModal"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
</div>
</Transition>
</template>
</div>
</template>
@@ -236,7 +226,6 @@ const route = useRoute()
const auth = injectAuth()
const client = injectModrinthClient()
const loggedIn = computed(() => !!auth.user.value)
const authReady = computed(() => auth.isReady?.value ?? true)
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -266,10 +255,6 @@ const messages = defineMessages({
defaultMessage: 'Contact Modrinth Support',
},
reloadButton: { id: 'servers.manage.reload-button', defaultMessage: 'Reload' },
loadingServers: {
id: 'servers.manage.loading-servers',
defaultMessage: 'Loading your servers...',
},
serversTitle: { id: 'servers.manage.servers-title', defaultMessage: 'Modrinth Hosting' },
searchPlaceholder: {
id: 'servers.manage.search-placeholder',
@@ -509,7 +494,7 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
const {
data: serverResponse,
error: fetchError,
isLoading,
isPending: serversQueryPending,
} = useQuery({
queryKey: ['servers'],
queryFn: async () => {
@@ -556,6 +541,9 @@ const {
const hasError = computed(() => loggedIn.value && !!fetchError.value)
/** Logged-in initial fetch: avoid treating "no data yet" as an empty server list. */
const showServersListLoading = computed(() => loggedIn.value && serversQueryPending.value)
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
if (!loggedIn.value || !serverResponse.value) return []
return serverResponse.value.servers

View File

@@ -35,6 +35,7 @@
</template>
<script setup lang="ts">
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
import type { Mclogs } from '@modrinth/api-client'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'

View File

@@ -95,14 +95,6 @@
</template>
</ErrorInformationCard>
</div>
<!-- Loading state (before serverData arrives) -->
<div
v-else-if="!serverData && !serverError"
class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 relative bottom-12"
>
<LoaderCircleIcon class="size-16 animate-spin" />
<span class="text-secondary">{{ formatMessage(loadingMessages.loadingServerPanel) }}</span>
</div>
<!-- SERVER START -->
<div
v-else-if="serverData"
@@ -120,14 +112,7 @@
},
]"
>
<div
v-if="revealState === 'pending' && !isOnboarding"
class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 relative bottom-12"
>
<LoaderCircleIcon class="size-16 animate-spin" />
<span class="text-secondary">{{ formatMessage(loadingMessages.loadingServerPanel) }}</span>
</div>
<template v-else>
<template v-if="revealState !== 'pending' || isOnboarding">
<ServerManageHeader
v-if="!isOnboarding"
class="server-stagger-item"
@@ -463,7 +448,9 @@ import {
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
import {
useDebugLogger,
useLoadingBarToken,
useModrinthServersConsole,
useReadyState,
useServerImage,
useServerProject,
} from '#ui/composables'
@@ -536,13 +523,6 @@ const props = withDefaults(
const { formatMessage } = useVIntl()
const loadingMessages = defineMessages({
loadingServerPanel: {
id: 'servers.manage.loading.serverPanel',
defaultMessage: 'Loading your server panel...',
},
})
const leaveMessages = defineMessages({
uploadInProgress: {
id: 'servers.manage.confirm-leave.upload-in-progress',
@@ -569,6 +549,9 @@ const settingsHintMessages = defineMessages({
},
})
// disabled, keeping the animation logic cos it's really nice and we might want to re-enable in future
const DISABLE_LOADING_ANIM = true
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
@@ -599,11 +582,17 @@ function dismissSettingsHint() {
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const { data: serverData, error: serverQueryError } = useQuery({
const {
data: serverData,
error: serverQueryError,
isLoading: serverLoading,
} = useQuery({
queryKey: ['servers', 'detail', props.serverId],
queryFn: () => client.archon.servers_v0.get(props.serverId)!,
})
useLoadingBarToken(useReadyState({ isLoading: serverLoading, data: serverData }))
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
if (!serverData.value) return
queryClient.setQueryData(['servers', 'detail', props.serverId], {
@@ -817,7 +806,7 @@ log('canReveal initial', {
})
const revealState = ref<'pending' | 'revealing' | 'visible'>(
canReveal.value ? 'visible' : 'pending',
DISABLE_LOADING_ANIM || canReveal.value ? 'visible' : 'pending',
)
log('revealState initial', revealState.value)
@@ -826,11 +815,15 @@ const REVEAL_TOTAL_MS = 2 * 80 + 400
watch(canReveal, (ready) => {
log('canReveal changed', { ready, revealState: revealState.value })
if (ready && revealState.value === 'pending') {
revealState.value = 'revealing'
setTimeout(() => {
if (DISABLE_LOADING_ANIM) {
revealState.value = 'visible'
log('revealState -> visible')
}, REVEAL_TOTAL_MS)
} else {
revealState.value = 'revealing'
setTimeout(() => {
revealState.value = 'visible'
log('revealState -> visible')
}, REVEAL_TOTAL_MS)
}
}
})