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

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