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

@@ -22,4 +22,5 @@ Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standar
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
- Replace any platform-specific imports with shared utilities.
- Import and render the wrapped page from both frontends as a simple component.
- If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`.
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.

View File

@@ -1 +0,0 @@
See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines.

View File

@@ -38,6 +38,7 @@ import {
CreationFlowModal,
defineMessages,
I18nDebugPanel,
LoadingBar,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -52,7 +53,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { formatBytes, renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
@@ -65,7 +66,6 @@ import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
@@ -113,8 +113,9 @@ import {
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useLoading, useTheming } from '@/store/state'
import { useTheming } from '@/store/state'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
@@ -420,9 +421,11 @@ const handleClose = async () => {
const router = useRouter()
const route = useRoute()
const loading = useLoading()
const loading = setupLoadingStateProvider()
loading.setEnabled(false)
loading.startLoading()
let initialLoadToken = loading.begin()
let routerToken = null
let suspenseToken = null
let suspensePending = false
@@ -435,7 +438,8 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
router.beforeEach(() => {
suspensePending = false
loading.startLoading()
if (routerToken) loading.end(routerToken)
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
trackEvent('PageView', {
@@ -445,11 +449,83 @@ router.afterEach((to, from, failure) => {
})
setTimeout(() => {
if (!suspensePending && stateInitialized.value) {
loading.stopLoading()
if (initialLoadToken) {
loading.end(initialLoadToken)
initialLoadToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
}
}, 100)
})
function onSuspensePending() {
suspensePending = true
if (suspenseToken) loading.end(suspenseToken)
suspenseToken = loading.begin()
}
function onSuspenseResolve() {
if (suspenseToken) {
loading.end(suspenseToken)
suspenseToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
}
const queryClient = useQueryClient()
watch(stateInitialized, (ready) => {
if (ready) {
if (initialLoadToken) {
loading.end(initialLoadToken)
initialLoadToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
queryClient.prefetchQuery({
queryKey: ['servers'],
queryFn: async () => {
const response = await tauriApiClient.archon.servers_v0.list({ limit: 100 })
const hasMedalServers = response.servers.some((s) => s.is_medal)
if (hasMedalServers) {
const subscriptions = await tauriApiClient.labrinth.billing_internal.getSubscriptions()
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 = new Date(
new Date(sub.created).getTime() + 5 * 86400000,
).toISOString()
}
}
}
}
return response
},
staleTime: 30_000,
})
queryClient.prefetchQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => tauriApiClient.labrinth.billing_internal.getSubscriptions(),
staleTime: 30_000,
})
queryClient.prefetchQuery({
queryKey: ['billing', 'payments'],
queryFn: () => tauriApiClient.labrinth.billing_internal.getPayments(),
staleTime: 30_000,
})
}
})
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
@@ -1236,7 +1312,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))',
}"
>
<ModrinthLoadingIndicator />
<LoadingBar position="absolute" />
</div>
<div
v-if="themeStore.featureFlags.page_path"
@@ -1272,19 +1348,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</Admonition>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense
@pending="
() => {
suspensePending = true
loading.startLoading()
}
"
@resolve="
() => {
loading.stopLoading()
}
"
>
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
</Suspense>
</template>

View File

@@ -1,142 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
const props = defineProps({
throttle: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 1000,
},
height: {
type: Number,
default: 2,
},
color: {
type: String,
default: 'var(--loading-bar-gradient)',
},
})
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return { progress, isLoading, start, finish, clear }
}
</script>
<template>
<div
class="loading-indicator-bar"
:style="{
'--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`,
right: `0`,
left: `${props.offsetWidth}`,
pointerEvents: 'none',
width: `var(--_width)`,
height: `var(--_height)`,
borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`,
background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6,
}"
>
<slot />
</div>
</template>
<style lang="scss" scoped>
.loading-indicator-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
width: var(--_width);
bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1);
z-index: 5;
transition:
width 0.1s ease-in-out,
opacity 0.1s ease-out;
}
</style>

View File

@@ -78,11 +78,11 @@
</template>
<script setup>
import { injectLoadingState } from '@modrinth/ui'
import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js'
import { useLoading } from '@/store/loading.js'
const doneLoading = ref(false)
const loadingProgress = ref(0)
@@ -91,20 +91,20 @@ const message = ref()
const MIN_DISPLAY_MS = 500
const mountedAt = Date.now()
const loading = useLoading()
const loading = injectLoadingState()
function onAfterLeave() {
loading.setEnabled(true)
}
watch(
loading,
(newValue) => {
if (newValue.barEnabled) {
[loading.barEnabled, loading.pending],
([barEnabled, pending]) => {
if (barEnabled) {
return
}
if (loading.loading) {
if (pending) {
loadingProgress.value = 0
fakeLoadingIncrease()
return
@@ -114,7 +114,7 @@ watch(
const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
setTimeout(() => {
if (loading.loading) {
if (loading.pending.value) {
return
}
doneLoading.value = true

View File

@@ -1,7 +1,26 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageBackupsPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageBackupsPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const { isServerRunning } = injectModrinthServerContext()
const client = injectModrinthClient()
const { serverId, worldId, isServerRunning } = injectModrinthServerContext()
const queryClient = useQueryClient()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
</script>
<template>

View File

@@ -1,5 +1,27 @@
<script setup lang="ts">
import { ServersManageContentPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageContentPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { serverId, worldId } = injectModrinthServerContext()
const queryClient = useQueryClient()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
</script>
<template>

View File

@@ -1,5 +1,24 @@
<script setup lang="ts">
import { ServersManageFilesPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageFilesPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
try {
await queryClient.ensureQueryData({
queryKey: ['files', serverId, '/'],
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
</script>
<template>

View File

@@ -35,9 +35,6 @@
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
@@ -48,8 +45,8 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
@@ -64,6 +61,8 @@ import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
@@ -72,6 +71,18 @@ const serverId = computed(() => {
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
})
if (serverId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'detail', serverId.value],
queryFn: () => client.archon.servers_v0.get(serverId.value)!,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,

View File

@@ -6,6 +6,7 @@ import {
FilePageLayout,
injectNotificationManager,
provideFileManager,
ReadyTransition,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
@@ -54,6 +55,8 @@ const messages = defineMessages({
const instanceRoot = ref('')
const items = ref<FileItem[]>([])
/** True until the first directory read for the current instance path finishes (initial load only). */
const firstPaintPending = ref(true)
const loading = ref(true)
const error = ref<Error | null>(null)
const currentPath = ref('')
@@ -123,6 +126,7 @@ async function refresh() {
items.value = []
} finally {
loading.value = false
firstPaintPending.value = false
}
}
@@ -305,6 +309,7 @@ watch(
() => props.instance.path,
async () => {
debug('watch instance.path: changed to', props.instance.path)
firstPaintPending.value = true
instanceRoot.value = await get_full_path(props.instance.path)
currentPath.value = ''
await refresh()
@@ -341,5 +346,7 @@ provideFileManager({
</script>
<template>
<FilePageLayout :show-refresh-button="true" />
<ReadyTransition :pending="firstPaintPending">
<FilePageLayout :show-refresh-button="true" />
</ReadyTransition>
</template>

View File

@@ -218,11 +218,7 @@
:key="instance.path"
>
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<Suspense :key="instance.path">
<component
:is="Component"
:instance="instance"
@@ -235,9 +231,6 @@
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
@@ -296,7 +289,6 @@ import {
ButtonStyled,
ContentPageHeader,
injectNotificationManager,
LoadingIndicator,
NavTabs,
OverflowMenu,
ServerOnlinePlayers,
@@ -304,6 +296,7 @@ import {
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
@@ -323,16 +316,17 @@ import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status } from '@/helpers/worlds'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
import { useBreadcrumbs } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -392,6 +386,14 @@ async function fetchInstance() {
}
fetchDeferredData()
if (instance.value) {
queryClient.prefetchQuery({
queryKey: ['worlds', instance.value.path],
queryFn: () => refreshWorlds(instance.value!.path),
staleTime: 30_000,
})
}
}
function fetchDeferredData() {
@@ -471,8 +473,6 @@ if (instance.value) {
})
}
const loadingBar = useLoading()
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {

View File

@@ -1,65 +1,67 @@
<template>
<ContentPageLayout>
<template #modals>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackContentBulkToggle"
@bulk:disable="handleModpackContentBulkToggle"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.linked_data?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (linkedModpackProject?.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="loading">
<ContentPageLayout>
<template #modals>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackContentBulkToggle"
@bulk:disable="handleModpackContentBulkToggle"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.linked_data?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (linkedModpackProject?.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>
</template>
<script setup lang="ts">
@@ -82,6 +84,7 @@ import {
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'

View File

@@ -37,22 +37,109 @@
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
@proceed="proceedDeleteWorld"
/>
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="text"
autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable
:placeholder="
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
"
/>
<div class="flex gap-2">
<ReadyTransition :pending="worldsReadyPending">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="text"
autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable
:placeholder="
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
"
/>
<div class="flex gap-2">
<ButtonStyled type="outlined">
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
<PlusIcon class="size-5" />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
</ButtonStyled>
</div>
</div>
<div class="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="filterPillClass(selectedFilters.length === 0)"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
:class="filterPillClass(selectedFilters.includes(option.id))"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
<ButtonStyled type="transparent" hover-color-fill="none">
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex flex-col w-full gap-2">
<WorldItem
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
:starting-instance="startingInstance"
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
:rendered-motd="
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'singleplayer'
? editWorldModal?.show(world)
: isManagedServerWorld(world)
? undefined
: editServerModal?.show(world)
"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
</div>
<EmptyState
v-else
type="empty-inbox"
:heading="formatMessage(messages.noWorldsHeading)"
:description="formatMessage(messages.noWorldsDescription)"
>
<template #actions>
<ButtonStyled type="outlined">
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
<PlusIcon class="size-5" />
@@ -70,94 +157,9 @@
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
</ButtonStyled>
</div>
</div>
<div class="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="filterPillClass(selectedFilters.length === 0)"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
:class="filterPillClass(selectedFilters.includes(option.id))"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
<ButtonStyled type="transparent" hover-color-fill="none">
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex flex-col w-full gap-2">
<WorldItem
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
:starting-instance="startingInstance"
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
:rendered-motd="
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'singleplayer'
? editWorldModal?.show(world)
: isManagedServerWorld(world)
? undefined
: editServerModal?.show(world)
"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
</div>
<EmptyState
v-else
type="empty-inbox"
:heading="formatMessage(messages.noWorldsHeading)"
:description="formatMessage(messages.noWorldsDescription)"
>
<template #actions>
<ButtonStyled type="outlined">
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
<PlusIcon class="size-5" />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
</ButtonStyled>
</template>
</EmptyState>
</template>
</EmptyState>
</ReadyTransition>
</template>
<script setup lang="ts">
import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
@@ -169,11 +171,14 @@ import {
GAME_MODES,
type GameVersion,
injectNotificationManager,
ReadyTransition,
StyledInput,
useReadyState,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { platform } from '@tauri-apps/plugin-os'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -344,11 +349,21 @@ function toggleFilter(id: string) {
}
}
const queryClient = useQueryClient()
const refreshingAll = ref(false)
const hadNoWorlds = ref(true)
const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worldsQuery = useQuery({
queryKey: computed(() => ['worlds', instance.value.path]),
queryFn: () => refreshWorlds(instance.value.path),
staleTime: 30_000,
})
const worldsReadyPending = useReadyState(worldsQuery)
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
@@ -358,6 +373,26 @@ const isLinux = platform() === 'linux'
const linuxRefreshCount = ref(0)
const protocolVersion = ref<ProtocolVersion | null>(null)
const gameVersions = ref<GameVersion[]>([])
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
watch(
() => worldsQuery.data.value,
(data) => {
if (data) {
worlds.value = [...data]
refreshServers(worlds.value, serverData.value, protocolVersion.value)
hadNoWorlds.value = worlds.value.length === 0
}
},
{ immediate: true },
)
const managedServerName = ref<string | null>(null)
const managedServerAddress = ref<string | null>(null)
@@ -385,8 +420,8 @@ async function refreshManagedServerMetadata() {
try {
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
get_project(projectId),
get_project_v3(projectId),
])
if (projectV3?.minecraft_server == null) {
@@ -422,27 +457,40 @@ watch(
{ immediate: true },
)
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
let unlistenProfile: (() => void) | null = null
let worldsTabAlive = true
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
async function initWorldsTab() {
const [_unlistenProfile, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
await refreshAllWorlds()
}
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
}),
refreshAllWorlds(),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
await refreshAllWorlds()
}
protocolVersion.value = resolvedProtocolVersion
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
}),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
if (!worldsTabAlive) {
_unlistenProfile()
return
}
unlistenProfile = _unlistenProfile
protocolVersion.value = resolvedProtocolVersion
gameVersions.value = resolvedGameVersions
}
await initWorldsTab()
async function refreshServer(address: string) {
if (!serverData.value[address]) {
@@ -458,26 +506,10 @@ async function refreshAllWorlds() {
console.log(`Already refreshing, cancelling refresh.`)
return
}
await refreshManagedServerMetadata()
refreshingAll.value = true
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0
if (hadNoWorlds.value && hasNoWorlds) {
setTimeout(() => {
refreshingAll.value = false
}, 1000)
} else {
refreshingAll.value = false
}
hadNoWorlds.value = hasNoWorlds
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.path] })
refreshingAll.value = false
}
async function addServer(server: ServerWorld) {
@@ -592,14 +624,6 @@ function worldsMatch(world: World, other: World | undefined) {
return false
}
const gameVersions = ref<GameVersion[]>(resolvedGameVersions)
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const dedupedWorlds = computed(() => {
const visibleWorlds: World[] = []
const serverIndexByDomain = new Map<string, number>()
@@ -749,7 +773,8 @@ async function proceedDeleteWorld() {
worldToDelete.value = undefined
}
onUnmounted(() => {
unlistenProfile()
onBeforeUnmount(() => {
worldsTabAlive = false
unlistenProfile?.()
})
</script>

View File

@@ -0,0 +1,17 @@
import type { LoadingStateProvider } from '@modrinth/ui'
import { createLoadingStateCore, provideLoadingState } from '@modrinth/ui'
/**
* Source of truth for the desktop app's loading state.
*
* Owns the token-based ref-counter directly (no Pinia store). Consumers
* obtain the same reactive state via `injectLoadingState()` from `@modrinth/ui`.
*
* Returns the provider so the call site (App.vue) can also use it directly
* without a second injection round-trip.
*/
export function setupLoadingStateProvider(): LoadingStateProvider {
const provider = createLoadingStateCore({ barEnabled: false })
provideLoadingState(provider)
return provider
}

View File

@@ -1,19 +0,0 @@
import { defineStore } from 'pinia'
export const useLoading = defineStore('loadingStore', {
state: () => ({
loading: false,
barEnabled: false,
}),
actions: {
setEnabled(enabled) {
this.barEnabled = enabled
},
startLoading() {
this.loading = true
},
stopLoading() {
this.loading = false
},
},
})

View File

@@ -1,5 +1,4 @@
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useTheming } from './theme.ts'
export { useBreadcrumbs, useLoading, useTheming }
export { useBreadcrumbs, useTheming }

View File

@@ -1,16 +1,15 @@
<template>
<NuxtLayout>
<NuxtRouteAnnouncer />
<ModrinthLoadingIndicator />
<LoadingBar />
<NotificationPanel />
<I18nDebugPanel />
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
import { I18nDebugPanel, NotificationPanel } from '@modrinth/ui'
import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { setupProviders } from '~/providers/setup.ts'
const auth = await useAuth()

View File

@@ -1,142 +0,0 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { startLoading, stopLoading, useNuxtApp } from '#imports'
export default defineComponent({
name: 'ModrinthLoadingIndicator',
props: {
throttle: {
type: Number,
default: 50,
},
duration: {
type: Number,
default: 500,
},
height: {
type: Number,
default: 3,
},
color: {
type: [String, Boolean],
default:
'repeating-linear-gradient(to right, var(--color-green) 0%, var(--landing-green-label) 100%)',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
const nuxtApp = useNuxtApp()
nuxtApp.hook('page:start', () => {
startLoading()
indicator.start()
})
nuxtApp.hook('page:finish', () => {
stopLoading()
indicator.finish()
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue) {
indicator.start()
} else {
indicator.finish()
}
})
return () =>
h(
'div',
{
class: 'nuxt-loading-indicator',
style: {
position: 'fixed',
top: 0,
right: 0,
left: 0,
pointerEvents: 'none',
width: `${indicator.progress.value}%`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 999999,
},
},
slots,
)
},
})
function useLoadingIndicator(opts: { duration: number; throttle: number }) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer: any = null
let _throttle: any = null
function start() {
clear()
progress.value = 0
if (opts.throttle && import.meta.client) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num: number) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
if (import.meta.client) {
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
}
function _startTimer() {
if (import.meta.client) {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
}
return {
progress,
isLoading,
start,
finish,
clear,
}
}

View File

@@ -1,6 +1,6 @@
<template>
<NuxtLayout>
<ModrinthLoadingIndicator />
<LoadingBar />
<NotificationPanel />
<div class="main experimental-styles-within">
<div v-if="is404" class="error-graphic">
@@ -55,6 +55,7 @@ import { SadRinthbot } from '@modrinth/assets'
import {
defineMessage,
IntlFormatted,
LoadingBar,
normalizeChildren,
NotificationPanel,
provideModrinthClient,
@@ -65,14 +66,15 @@ import {
import Logo404 from '~/assets/images/404.svg'
import ModrinthLoadingIndicator from './components/ui/modrinth-loading-indicator.ts'
import { createModrinthClient } from './helpers/api.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
import { setupLoadingStateProvider } from './providers/setup/loading-state.ts'
const auth = await useAuth()
const config = useRuntimeConfig()
provideNotificationManager(new FrontendNotificationManager())
setupLoadingStateProvider()
const client = createModrinthClient(auth.value, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),

View File

@@ -37,7 +37,8 @@
</template>
<script setup lang="ts">
import { ServersManageRootLayout } from '@modrinth/ui'
import { injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { reloadNuxtApp } from '#app'
import { products } from '~/generated/state.json'
@@ -48,6 +49,21 @@ const router = useRouter()
const config = useRuntimeConfig()
const serverId = route.params.id as string
const client = injectModrinthClient()
const queryClient = useQueryClient()
if (serverId) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'detail', serverId],
queryFn: () => client.archon.servers_v0.get(serverId)!,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
const auth = (await useAuth()) as unknown as {
value: { user: { id: string; username: string; email: string; created: string } }
}

View File

@@ -1,9 +1,28 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageBackupsPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageBackupsPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const { server, isServerRunning } = injectModrinthServerContext()
const client = injectModrinthClient()
const { server, serverId, worldId, isServerRunning } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
useHead({
title: `Backups - ${server.value?.name ?? 'Server'} - Modrinth`,
})

View File

@@ -1,7 +1,27 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageContentPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageContentPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const { server } = injectModrinthServerContext()
const client = injectModrinthClient()
const { server, serverId, worldId } = injectModrinthServerContext()
const queryClient = useQueryClient()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
useHead({
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,

View File

@@ -1,9 +1,26 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageFilesPage } from '@modrinth/ui'
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageFilesPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const { server } = injectModrinthServerContext()
const client = injectModrinthClient()
const { server, serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
try {
await queryClient.ensureQueryData({
queryKey: ['files', serverId, '/'],
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
useHead({
title: computed(() => `Files - ${server.value?.name ?? 'Server'} - Modrinth`),
})

View File

@@ -3,6 +3,7 @@ import { provideNotificationManager } from '@modrinth/ui'
import { FrontendNotificationManager } from './frontend-notifications'
import { setupAuthProvider } from './setup/auth'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupLoadingStateProvider } from './setup/loading-state'
import { setupModrinthClientProvider } from './setup/modrinth-client'
import { setupPageContextProvider } from './setup/page-context'
import { setupTagsProvider } from './setup/tags'
@@ -15,4 +16,5 @@ export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
setupTagsProvider()
setupFilePickerProvider()
setupPageContextProvider()
setupLoadingStateProvider()
}

View File

@@ -0,0 +1,49 @@
import type { LoadingStateProvider } from '@modrinth/ui'
import { createLoadingStateCore, provideLoadingState } from '@modrinth/ui'
import { watch } from 'vue'
/**
* Initialize the cross-platform loading-state provider for the website.
*
* Responsibilities:
* 1. Own the token-based ref-counter that drives `LoadingBar` and `ReadyTransition`.
* 2. Bridge the legacy `useState('loading')` global so the many existing
* `startLoading()` / `stopLoading()` call sites continue to raise the bar.
* 3. Register Nuxt `page:start` / `page:finish` hooks so route navigation
* auto-fires the bar (replaces the behavior previously inside
* `modrinth-loading-indicator.ts`).
*/
export function setupLoadingStateProvider(): LoadingStateProvider {
const provider = createLoadingStateCore({ barEnabled: true })
provideLoadingState(provider)
const legacyState = useLoading()
let legacyToken: symbol | null = null
watch(
legacyState,
(value) => {
if (value && !legacyToken) {
legacyToken = provider.begin()
} else if (!value && legacyToken) {
provider.end(legacyToken)
legacyToken = null
}
},
{ immediate: true },
)
const nuxtApp = useNuxtApp()
let pageToken: symbol | null = null
nuxtApp.hook('page:start', () => {
if (pageToken) provider.end(pageToken)
pageToken = provider.begin()
})
nuxtApp.hook('page:finish', () => {
if (pageToken) {
provider.end(pageToken)
pageToken = null
}
})
return provider
}

View File

@@ -31,6 +31,7 @@ use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
@@ -284,15 +285,24 @@ async fn get_singleplayer_worlds_in_profile(
if !saves_dir.exists() {
return Ok(());
}
let mut saves_dir = io::read_dir(saves_dir).await?;
while let Some(world_dir) = saves_dir.next_entry().await? {
let mut entries = io::read_dir(&saves_dir).await?;
let mut tasks = JoinSet::new();
while let Some(world_dir) = entries.next_entry().await? {
let world_path = world_dir.path();
let level_dat_path = world_path.join("level.dat");
if !level_dat_path.exists() {
if !world_path.join("level.dat").exists() {
continue;
}
if let Ok(world) = read_singleplayer_world(world_path).await {
worlds.push(world);
tasks.spawn(read_singleplayer_world(world_path));
}
while let Some(result) = tasks.join_next().await {
match result {
Ok(Ok(world)) => worlds.push(world),
Ok(Err(e)) => {
tracing::warn!("Skipping unreadable world: {e}");
}
Err(e) => {
tracing::warn!("World read task panicked: {e}");
}
}
}
@@ -333,36 +343,36 @@ async fn read_singleplayer_world_maybe_locked(
world_path: PathBuf,
locked: bool,
) -> Result<World> {
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct LevelDataRoot {
data: LevelData,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct LevelData {
#[serde(default)]
level_name: String,
#[serde(default)]
last_played: i64,
#[serde(default)]
game_type: i32,
#[serde(default, rename = "hardcore")]
hardcore: bool,
}
let level_data = io::read(world_path.join("level.dat")).await?;
let level_data: LevelDataRoot = quartz_nbt::serde::deserialize(
&level_data,
let raw = io::read(world_path.join("level.dat")).await?;
let (root, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(raw),
quartz_nbt::io::Flavor::GzCompressed,
)?
.0;
let level_data = level_data.data;
)?;
let icon = Some(world_path.join("icon.png")).filter(|i| i.exists());
let data = root.get::<_, &NbtCompound>("Data").map_err(|_| {
Error::from(ErrorKind::InputError(
"Missing Data tag in level.dat".into(),
))
})?;
let game_mode = match level_data.game_type {
let level_name = data
.get::<_, &str>("LevelName")
.unwrap_or_default()
.to_string();
let last_played = data.get::<_, i64>("LastPlayed").unwrap_or(0);
let game_type = data.get::<_, i32>("GameType").unwrap_or(0);
let hardcore = data.get::<_, i8>("hardcore").unwrap_or(0) != 0;
let icon = if tokio::fs::try_exists(world_path.join("icon.png"))
.await
.unwrap_or(false)
{
Some(Either::Left(world_path.join("icon.png")))
} else {
None
};
let game_mode = match game_type {
0 => SingleplayerGameMode::Survival,
1 => SingleplayerGameMode::Creative,
2 => SingleplayerGameMode::Adventure,
@@ -371,9 +381,9 @@ async fn read_singleplayer_world_maybe_locked(
};
Ok(World {
name: level_data.level_name,
last_played: Utc.timestamp_millis_opt(level_data.last_played).single(),
icon: icon.map(Either::Left),
name: level_name,
last_played: Utc.timestamp_millis_opt(last_played).single(),
icon,
display_status: DisplayStatus::Normal,
details: WorldDetails::Singleplayer {
path: world_path
@@ -382,7 +392,7 @@ async fn read_singleplayer_world_maybe_locked(
.to_string_lossy()
.to_string(),
game_mode,
hardcore: level_data.hardcore,
hardcore,
locked,
},
})

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { injectLoadingState } from '#ui/providers/loading-state'
const props = withDefaults(
defineProps<{
/** Bar height in pixels. */
height?: number
/** Background gradient. Defaults to the brand green. */
color?: string
/** Total bar fill duration in ms (visual progress easing). */
duration?: number
/** Delay in ms before the bar becomes visible after a load begins. */
throttle?: number
/** CSS position. Use `absolute` when wrapping in a custom positioned container (e.g. desktop top-bar offset). */
position?: 'fixed' | 'absolute'
/** Top offset CSS value. */
offsetTop?: string
/** Left offset CSS value. */
offsetLeft?: string
/** Right offset CSS value. */
offsetRight?: string
}>(),
{
height: 2,
color: 'var(--loading-bar-gradient)',
duration: 1000,
throttle: 0,
position: 'fixed',
offsetTop: '0',
offsetLeft: '0',
offsetRight: '0',
},
)
const loadingState = injectLoadingState(null)
const progress = ref(0)
const isVisible = ref(false)
const step = computed(() => 10000 / props.duration)
let _timer: ReturnType<typeof setInterval> | null = null
let _throttle: ReturnType<typeof setTimeout> | null = null
let _hideTimeout: ReturnType<typeof setTimeout> | null = null
let _resetTimeout: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (_timer) clearInterval(_timer)
if (_throttle) clearTimeout(_throttle)
if (_hideTimeout) clearTimeout(_hideTimeout)
if (_resetTimeout) clearTimeout(_resetTimeout)
_timer = null
_throttle = null
_hideTimeout = null
_resetTimeout = null
}
function startTimer() {
if (typeof window === 'undefined') return
_timer = setInterval(() => {
progress.value = Math.min(100, progress.value + step.value)
}, 100)
}
function start() {
clearTimers()
progress.value = 0
if (props.throttle && typeof window !== 'undefined') {
_throttle = setTimeout(() => {
isVisible.value = true
startTimer()
}, props.throttle)
} else {
isVisible.value = true
startTimer()
}
}
function finish() {
progress.value = 100
clearTimers()
if (typeof window === 'undefined') {
isVisible.value = false
progress.value = 0
return
}
_hideTimeout = setTimeout(() => {
isVisible.value = false
_resetTimeout = setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
if (loadingState) {
watch(
() => loadingState.pending.value && loadingState.barEnabled.value,
(active) => {
if (active) start()
else finish()
},
{ immediate: true },
)
}
onBeforeUnmount(clearTimers)
</script>
<template>
<div
class="modrinth-loading-bar"
:style="{
position: props.position,
top: props.offsetTop,
right: props.offsetRight,
left: props.offsetLeft,
pointerEvents: 'none',
width: `${progress}%`,
height: `${isVisible ? props.height : 0}px`,
borderRadius: `${props.height}px`,
background: props.color,
backgroundSize: `${(100 / Math.max(progress, 0.01)) * 100}% auto`,
opacity: isVisible ? 1 : 0,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out, opacity 0.4s',
}"
/>
</template>
<style lang="scss" scoped>
.modrinth-loading-bar {
z-index: 999999;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: 0.1;
z-index: -1;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* If `pending` is false on mount and never becomes true, the slot renders with no
* enter transition (cache-hit fast path). After a real pending phase, transitions
* behave as before for subsequent toggles.
*/
import type { Ref } from 'vue'
import { computed, onBeforeUnmount, ref, toRef, watch } from 'vue'
import { injectLoadingState } from '#ui/providers/loading-state'
const props = withDefaults(
defineProps<{
/** True while the wrapped content is still loading. Slot stays blank, loading bar runs. */
pending: boolean | Ref<boolean>
/** Fade duration applied to the slot when content reveals. */
duration?: number
/** When true, do NOT register a token with the global loading bar — only fade locally. */
silent?: boolean
}>(),
{
duration: 200,
silent: false,
},
)
const pendingRef = toRef(props, 'pending') as Ref<boolean | Ref<boolean>>
const resolvedPending = computed(() => {
const v = pendingRef.value
if (typeof v === 'boolean') return v
return Boolean((v as Ref<boolean>).value)
})
const hasBeenPending = ref(false)
const useShell = computed(() => resolvedPending.value || hasBeenPending.value)
const loadingState = injectLoadingState(null)
let token: symbol | null = null
function release() {
if (token && loadingState) {
loadingState.end(token)
}
token = null
}
watch(
resolvedPending,
(now) => {
if (now) {
hasBeenPending.value = true
}
if (loadingState && !props.silent && typeof window !== 'undefined') {
if (now) {
if (!token) token = loadingState.begin()
} else {
release()
}
}
},
{ immediate: true },
)
onBeforeUnmount(release)
</script>
<template>
<template v-if="useShell">
<Transition name="ready-fade" mode="out-in" :duration="props.duration">
<div v-if="!resolvedPending" key="content" class="ready-transition-content">
<slot />
</div>
<div v-else key="pending" aria-hidden="true" class="ready-transition-pending" />
</Transition>
</template>
<slot v-else />
</template>
<style scoped>
.ready-fade-enter-active,
.ready-fade-leave-active {
transition: opacity v-bind('`${props.duration}ms`') ease-in-out;
}
.ready-fade-enter-from,
.ready-fade-leave-to {
opacity: 0;
}
.ready-transition-content {
width: 100%;
}
.ready-transition-pending {
width: 100%;
height: 100%;
}
</style>

View File

@@ -43,6 +43,7 @@ export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
@@ -62,6 +63,7 @@ export { default as ProgressBar } from './ProgressBar.vue'
export { default as ProgressSpinner } from './ProgressSpinner.vue'
export { default as RadialHeader } from './RadialHeader.vue'
export { default as RadioButtons } from './RadioButtons.vue'
export { default as ReadyTransition } from './ReadyTransition.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'

View File

@@ -13,6 +13,9 @@ export * from './server-console'
export * from './server-manage-core-runtime'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './use-server-project'
export * from './virtual-scroll'

View File

@@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { onBeforeUnmount, watch } from 'vue'
import { injectLoadingState } from '#ui/providers/loading-state'
/**
* Register a `LoadingBar` token for as long as `pending` is truthy.
*
* Use this when the component that owns the load is not the natural place
* to mount a `<ReadyTransition>` (e.g. a page root with a complex v-if
* cascade where wrapping the template is awkward). `<ReadyTransition>`
* remains the preferred API when it fits.
*
* Safe to call without a provider mounted; becomes a no-op.
*/
export function useLoadingBarToken(pending: Ref<boolean>): void {
const loadingState = injectLoadingState(null)
if (!loadingState) return
let token: symbol | null = null
function release() {
if (token) {
loadingState.end(token)
token = null
}
}
watch(
pending,
(now) => {
if (typeof window === 'undefined') return
if (now && !token) {
token = loadingState.begin()
} else if (!now) {
release()
}
},
{ immediate: true },
)
onBeforeUnmount(release)
}

View File

@@ -0,0 +1,61 @@
import { computed, ref, shallowRef } from 'vue'
import type { LoadingStateProvider } from '#ui/providers/loading-state'
export interface LoadingStateCoreOptions {
/** Initial value of the host kill-switch. Default: true. */
barEnabled?: boolean
}
/**
* Build a token-based `LoadingStateProvider` implementation.
*
* Multiple `ReadyTransition` instances (or any caller) can hold tokens at the
* same time; the bar stays visible while at least one is live. `end(token)`
* is idempotent so a stale token release after unmount is harmless.
*
* SSR safe: timers and DOM access are deferred to component code; this core
* is pure reactive state.
*/
export function createLoadingStateCore(opts: LoadingStateCoreOptions = {}): LoadingStateProvider {
const tokens = shallowRef<Set<symbol>>(new Set())
const barEnabled = ref(opts.barEnabled ?? true)
const pending = computed(() => tokens.value.size > 0)
function begin(): symbol {
const token = Symbol('loading-state-token')
const next = new Set(tokens.value)
next.add(token)
tokens.value = next
return token
}
function end(token: symbol): void {
if (!tokens.value.has(token)) return
const next = new Set(tokens.value)
next.delete(token)
tokens.value = next
}
function beginManual(durationMs = 500): void {
const token = begin()
if (typeof window === 'undefined') {
end(token)
return
}
window.setTimeout(() => end(token), durationMs)
}
function setEnabled(enabled: boolean): void {
barEnabled.value = enabled
}
return {
pending,
barEnabled,
begin,
end,
beginManual,
setEnabled,
}
}

View File

@@ -0,0 +1,24 @@
import type { DefaultError, UseQueryReturnType } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { computed } from 'vue'
/** Subset of {@link UseQueryReturnType} passed to {@link useReadyState}. */
export type ReadyStateQuery<TData, TError = DefaultError> = Pick<
UseQueryReturnType<TData, TError>,
'isLoading' | 'data'
>
/**
* Returns true while a query is loading for the FIRST time (no cached data yet).
*
* Excludes background refetches and refetch-on-window-focus by design — those
* have `isLoading === false` once data exists in the cache, so `ReadyTransition`
* stays open and the loading bar stays silent.
*
* Pair with `<ReadyTransition :pending="var which is useReadyState(query)" />`.
*/
export function useReadyState<TData, TError = DefaultError>(
query: ReadyStateQuery<TData, TError>,
): Readonly<Ref<boolean>> {
return computed(() => query.isLoading.value && query.data.value === undefined)
}

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)
}
}
})

View File

@@ -644,9 +644,6 @@
"files.layout.extraction-started-title": {
"defaultMessage": "Extraction started"
},
"files.layout.loading": {
"defaultMessage": "Loading files..."
},
"files.layout.selected-count": {
"defaultMessage": "{count} selected"
},
@@ -2876,12 +2873,6 @@
"servers.manage.handle-error.title": {
"defaultMessage": "An error occurred"
},
"servers.manage.loading-servers": {
"defaultMessage": "Loading your servers..."
},
"servers.manage.loading.serverPanel": {
"defaultMessage": "Loading your server panel..."
},
"servers.manage.new-server-button": {
"defaultMessage": "New server"
},

View File

@@ -7,6 +7,7 @@ export * from './file-picker'
export * from './hosting-purchase-intent'
export * from './i18n'
export * from './instance-import'
export * from './loading-state'
export * from './modal-behavior'
export * from './page-context'
export * from './popup-notifications'

View File

@@ -0,0 +1,27 @@
import type { Ref } from 'vue'
import { createContext } from './create-context'
/**
* Cross-platform loading-state contract injected by the host app.
* Consumed by the shared `LoadingBar` and `ReadyTransition` components.
*/
export interface LoadingStateProvider {
/** True iff at least one active load token is registered. */
readonly pending: Readonly<Ref<boolean>>
/** Host-level kill switch (e.g. disable the bar during a splash screen). */
readonly barEnabled: Readonly<Ref<boolean>>
/** Begin a tracked load. Returns a unique token; pair with `end(token)`. */
begin(): symbol
/** End a previously-begun load. Idempotent — unknown or repeat tokens are silently ignored. */
end(token: symbol): void
/** Fire a synthetic load that auto-releases after `durationMs` (default 500ms). For manual-refresh buttons. */
beginManual(durationMs?: number): void
/** Toggle the bar at the host level. */
setEnabled(enabled: boolean): void
}
export const [injectLoadingState, provideLoadingState] = createContext<LoadingStateProvider>(
'root',
'loadingState',
)

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { onMounted, ref } from 'vue'
import LoadingBar from '../../components/base/LoadingBar.vue'
import { createLoadingStateCore } from '../../composables/use-loading-state-core'
import { provideLoadingState } from '../../providers/loading-state'
const meta = {
title: 'Base/LoadingBar',
component: LoadingBar,
} satisfies Meta<typeof LoadingBar>
export default meta
type Story = StoryObj<typeof meta>
export const Idle: Story = {
render: () => ({
components: { LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
return {}
},
template: `
<div class="relative h-32 w-full">
<LoadingBar />
<p class="text-secondary">Loading bar is idle (no active tokens).</p>
</div>
`,
}),
}
export const SinglePending: Story = {
render: () => ({
components: { LoadingBar },
setup() {
const core = createLoadingStateCore()
provideLoadingState(core)
onMounted(() => {
core.begin()
})
return {}
},
template: `
<div class="relative h-32 w-full">
<LoadingBar />
<p class="text-secondary">One token registered — bar fills to 100% over 1s.</p>
</div>
`,
}),
}
export const StackedPending: Story = {
render: () => ({
components: { LoadingBar },
setup() {
const core = createLoadingStateCore()
provideLoadingState(core)
const tokens: symbol[] = []
onMounted(() => {
tokens.push(core.begin())
tokens.push(core.begin())
setTimeout(() => core.end(tokens[0]!), 1500)
setTimeout(() => core.end(tokens[1]!), 3000)
})
return {}
},
template: `
<div class="relative h-32 w-full">
<LoadingBar />
<p class="text-secondary">Two tokens. First releases at 1.5s, second at 3s — bar stays visible until both end.</p>
</div>
`,
}),
}
export const ManualRefresh: Story = {
render: () => ({
components: { LoadingBar },
setup() {
const core = createLoadingStateCore()
provideLoadingState(core)
const last = ref<string>('idle')
function trigger() {
core.beginManual(800)
last.value = `beginManual(800) at ${new Date().toLocaleTimeString()}`
}
return { trigger, last }
},
template: `
<div class="relative h-32 w-full">
<LoadingBar />
<button class="rounded bg-button-bg px-3 py-2 text-contrast" @click="trigger">Manual refresh</button>
<p class="text-secondary mt-2">{{ last }}</p>
</div>
`,
}),
}

View File

@@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { onMounted, ref } from 'vue'
import LoadingBar from '../../components/base/LoadingBar.vue'
import ReadyTransition from '../../components/base/ReadyTransition.vue'
import { createLoadingStateCore } from '../../composables/use-loading-state-core'
import { provideLoadingState } from '../../providers/loading-state'
const meta = {
title: 'Base/ReadyTransition',
component: ReadyTransition,
} satisfies Meta<typeof ReadyTransition>
export default meta
type Story = StoryObj<typeof meta>
export const Idle: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
return { pending: ref(false) }
},
template: `
<div class="relative">
<LoadingBar />
<ReadyTransition :pending="pending">
<div class="rounded bg-bg-raised p-4 text-contrast">Slot content (already ready).</div>
</ReadyTransition>
</div>
`,
}),
}
/** Pending false from mount — no enter fade (cache-hit path). */
export const CacheHit: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
return { pending: ref(false) }
},
template: `
<div class="relative">
<LoadingBar />
<p class="text-secondary mb-4">pending stays false — content should appear with no fade-in.</p>
<ReadyTransition :pending="pending">
<div class="rounded bg-bg-raised p-4 text-contrast">Cached content visible immediately.</div>
</ReadyTransition>
</div>
`,
}),
}
/** Cold load: pending true then false — fade-in runs. */
export const ColdLoad: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
const pending = ref(true)
onMounted(() => {
setTimeout(() => (pending.value = false), 600)
})
return { pending }
},
template: `
<div class="relative">
<LoadingBar />
<p class="text-secondary mb-4">Pending 600ms then ready — content fades in; bar runs while pending.</p>
<ReadyTransition :pending="pending">
<div class="rounded bg-bg-raised p-4 text-contrast">Content after cold load.</div>
</ReadyTransition>
</div>
`,
}),
}
export const PendingThenReady: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
const pending = ref(true)
onMounted(() => {
setTimeout(() => (pending.value = false), 2000)
})
return { pending }
},
template: `
<div class="relative">
<LoadingBar />
<p class="text-secondary mb-4">Pending for 2s, then content fades in. Bar runs at top.</p>
<ReadyTransition :pending="pending">
<div class="rounded bg-bg-raised p-4 text-contrast">Slot content revealed.</div>
</ReadyTransition>
</div>
`,
}),
}
export const StackedTwoTransitions: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
const a = ref(true)
const b = ref(true)
onMounted(() => {
setTimeout(() => (a.value = false), 1500)
setTimeout(() => (b.value = false), 3000)
})
return { a, b }
},
template: `
<div class="relative grid gap-4">
<LoadingBar />
<ReadyTransition :pending="a">
<div class="rounded bg-bg-raised p-4 text-contrast">Panel A (ready at 1.5s).</div>
</ReadyTransition>
<ReadyTransition :pending="b">
<div class="rounded bg-bg-raised p-4 text-contrast">Panel B (ready at 3s).</div>
</ReadyTransition>
<p class="text-secondary">Bar stays visible until BOTH panels resolve.</p>
</div>
`,
}),
}
export const Silent: Story = {
render: () => ({
components: { ReadyTransition, LoadingBar },
setup() {
provideLoadingState(createLoadingStateCore())
const pending = ref(true)
onMounted(() => {
setTimeout(() => (pending.value = false), 1500)
})
return { pending }
},
template: `
<div class="relative">
<LoadingBar />
<p class="text-secondary mb-4">silent=true — fades locally but does NOT raise the loading bar.</p>
<ReadyTransition :pending="pending" silent>
<div class="rounded bg-bg-raised p-4 text-contrast">Silent slot content.</div>
</ReadyTransition>
</div>
`,
}),
}

View File

@@ -145,6 +145,56 @@ import { ServersManageContentPage } from '@modrinth/ui'
</template>
```
### Platform route shells: prefetch with `ensureQueryData`
#### Wrapped layout: `ReadyTransition` and `useReadyState`
Many wrapped pages wrap the main UI in [`ReadyTransition`](../../packages/ui/src/components/base/ReadyTransition.vue) with `:pending` driven by [`useReadyState`](../../packages/ui/src/composables/use-ready-state.ts) on the **primary** TanStack query (true only on the first load while that query has no cached data yet—background refetches stay “ready”). That avoids flashing empty content before data exists.
```vue
<!-- Conceptual: inside packages/ui wrapped layout -->
<ReadyTransition :pending="readyPending">
<SomePageLayout />
</ReadyTransition>
```
```ts
const primaryQuery = useQuery({ /* ... */ })
const readyPending = useReadyState(primaryQuery)
// or useReadyState({ isLoading, data }) when not using the full query object
```
Shell prefetch (below) warms the cache so that on navigation the query often **already has data** when the layout mounts; `pending` stays false and `ReadyTransition` can skip the enter animation on that fast path (see `ReadyTransition` docs and stories).
#### Rule: `ensureQueryData` in each platform route shell
When a wrapped layout uses that pattern, the **thin platform page** that imports the layout must **prefetch the same primary query** in `<script setup>` so the cache is warm before the layout mounts and `ReadyTransition`/`useReadyState` behave as intended.
**Rule:** For each primary `useQuery` in the wrapped layout that gates first paint (and thus `useReadyState` / `ReadyTransition`), the website and app route shells must call `queryClient.ensureQueryData` with the **same** `queryKey`, `queryFn`, and `staleTime` as that query. Wrap the call in `try/catch` and swallow errors so navigation does not fail during setup; the mounted layouts `useQuery` still runs and surfaces errors to the user.
```ts
import { injectModrinthClient, injectModrinthServerContext, ServersManageFilesPage } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
try {
await queryClient.ensureQueryData({
queryKey: ['files', serverId, '/'],
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
staleTime: 30_000,
})
} catch {
// Let the mounted layouts useQuery surface errors; do not fail route setup.
}
```
If a route parameter is required for the query (e.g. `worldId`), only call `ensureQueryData` when that value is present, matching the layouts `enabled` logic.
Duplicating the query definition in the shell is intentional until a shared query-options module exists; keep keys and fetchers aligned when editing the layout or the shell.
A wrapped page may still compose shared layouts internally — for example, the hosting content page uses the shared `content-tab` layout, providing its own `ContentManagerContext` with web API calls.
## Composables