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:
@@ -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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines.
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
<ReadyTransition :pending="firstPaintPending">
|
||||
<FilePageLayout :show-refresh-button="true" />
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<ReadyTransition :pending="loading">
|
||||
<ContentPageLayout>
|
||||
<template #modals>
|
||||
<ShareModalWrapper
|
||||
@@ -60,6 +61,7 @@
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -82,6 +84,7 @@ import {
|
||||
type OverflowMenuOption,
|
||||
provideAppBackup,
|
||||
provideContentManager,
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
|
||||
@proceed="proceedDeleteWorld"
|
||||
/>
|
||||
<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
|
||||
@@ -158,6 +159,7 @@
|
||||
</ButtonStyled>
|
||||
</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,7 +457,11 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||
let unlistenProfile: (() => void) | null = null
|
||||
let worldsTabAlive = true
|
||||
|
||||
async function initWorldsTab() {
|
||||
const [_unlistenProfile, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||
profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
|
||||
@@ -437,12 +476,21 @@ const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await
|
||||
|
||||
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
||||
}),
|
||||
refreshAllWorlds(),
|
||||
get_profile_protocol_version(instance.value.path).catch(() => null),
|
||||
get_game_versions().catch(() => [] as GameVersion[]),
|
||||
])
|
||||
])
|
||||
|
||||
protocolVersion.value = resolvedProtocolVersion
|
||||
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(() => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.path] })
|
||||
refreshingAll.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
refreshingAll.value = false
|
||||
}
|
||||
|
||||
hadNoWorlds.value = hasNoWorlds
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
17
apps/app-frontend/src/providers/setup/loading-state.ts
Normal file
17
apps/app-frontend/src/providers/setup/loading-state.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useBreadcrumbs } from './breadcrumbs'
|
||||
import { useLoading } from './loading'
|
||||
import { useTheming } from './theme.ts'
|
||||
|
||||
export { useBreadcrumbs, useLoading, useTheming }
|
||||
export { useBreadcrumbs, useTheming }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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/', '/'),
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
49
apps/frontend/src/providers/setup/loading-state.ts
Normal file
49
apps/frontend/src/providers/setup/loading-state.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
148
packages/ui/src/components/base/LoadingBar.vue
Normal file
148
packages/ui/src/components/base/LoadingBar.vue
Normal 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>
|
||||
98
packages/ui/src/components/base/ReadyTransition.vue
Normal file
98
packages/ui/src/components/base/ReadyTransition.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal file
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal 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)
|
||||
}
|
||||
61
packages/ui/src/composables/use-loading-state-core.ts
Normal file
61
packages/ui/src/composables/use-loading-state-core.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
24
packages/ui/src/composables/use-ready-state.ts
Normal file
24
packages/ui/src/composables/use-ready-state.ts
Normal 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)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
SpinnerIcon,
|
||||
TextCursorInputIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
@@ -504,18 +503,9 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<template v-if="!ctx.loading.value">
|
||||
<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"
|
||||
v-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">
|
||||
@@ -569,7 +559,11 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
|
||||
<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>
|
||||
@@ -803,6 +797,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<ContentSelectionBar
|
||||
:selected-items="selectedItems"
|
||||
|
||||
@@ -31,17 +31,7 @@
|
||||
><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-else key="content" class="contents">
|
||||
<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) }}
|
||||
@@ -204,7 +194,6 @@
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
</Transition>
|
||||
</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.',
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<ReadyTransition :pending="backupsReadyPending">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
@@ -46,6 +47,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<template v-if="backupsData">
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
@@ -53,11 +55,6 @@
|
||||
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"
|
||||
@@ -77,7 +74,6 @@
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||
@@ -107,7 +103,9 @@
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
||||
skipConfirmation
|
||||
? deleteBackup(backup)
|
||||
: deleteBackupModal?.show(backup)
|
||||
"
|
||||
@retry="() => retryBackup(backup.id)"
|
||||
/>
|
||||
@@ -117,6 +115,7 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
@@ -136,13 +135,14 @@
|
||||
</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),
|
||||
|
||||
@@ -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,6 +910,7 @@ provideContentManager({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="contentReadyPending">
|
||||
<ContentPageLayout>
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
@@ -950,6 +955,7 @@ provideContentManager({
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
|
||||
@@ -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>
|
||||
<ReadyTransition :pending="filesReadyPending">
|
||||
<FilePageLayout
|
||||
:show-debug-info="props.showDebugInfo"
|
||||
:show-refresh-button="props.showRefreshButton"
|
||||
/>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
|
||||
@@ -80,16 +80,36 @@
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="showServersListLoading" key="loading" class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
@@ -116,34 +136,6 @@
|
||||
</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"
|
||||
@@ -183,12 +175,10 @@
|
||||
: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>
|
||||
</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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,12 +815,16 @@ const REVEAL_TOTAL_MS = 2 * 80 + 400
|
||||
watch(canReveal, (ready) => {
|
||||
log('canReveal changed', { ready, revealState: revealState.value })
|
||||
if (ready && revealState.value === 'pending') {
|
||||
if (DISABLE_LOADING_ANIM) {
|
||||
revealState.value = 'visible'
|
||||
} else {
|
||||
revealState.value = 'revealing'
|
||||
setTimeout(() => {
|
||||
revealState.value = 'visible'
|
||||
log('revealState -> visible')
|
||||
}, REVEAL_TOTAL_MS)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(isConnected, (connected) => {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
27
packages/ui/src/providers/loading-state.ts
Normal file
27
packages/ui/src/providers/loading-state.ts
Normal 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',
|
||||
)
|
||||
97
packages/ui/src/stories/base/LoadingBar.stories.ts
Normal file
97
packages/ui/src/stories/base/LoadingBar.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
151
packages/ui/src/stories/base/ReadyTransition.stories.ts
Normal file
151
packages/ui/src/stories/base/ReadyTransition.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -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 layout’s `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 layout’s 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 layout’s `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
|
||||
|
||||
Reference in New Issue
Block a user