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.
|
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
|
||||||
- Replace any platform-specific imports with shared utilities.
|
- Replace any platform-specific imports with shared utilities.
|
||||||
- Import and render the wrapped page from both frontends as a simple component.
|
- 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.
|
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,
|
CreationFlowModal,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
I18nDebugPanel,
|
I18nDebugPanel,
|
||||||
|
LoadingBar,
|
||||||
NewsArticleCard,
|
NewsArticleCard,
|
||||||
NotificationPanel,
|
NotificationPanel,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
@@ -52,7 +53,7 @@ import {
|
|||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { formatBytes, renderString } from '@modrinth/utils'
|
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 { getVersion } from '@tauri-apps/api/app'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
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 { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||||
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
|
||||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||||
@@ -113,8 +113,9 @@ import {
|
|||||||
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
|
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
|
||||||
import { setupProviders } from '@/providers/setup'
|
import { setupProviders } from '@/providers/setup'
|
||||||
import { setupAuthProvider } from '@/providers/setup/auth'
|
import { setupAuthProvider } from '@/providers/setup/auth'
|
||||||
|
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
|
||||||
import { useError } from '@/store/error.js'
|
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 { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||||
@@ -420,9 +421,11 @@ const handleClose = async () => {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const loading = useLoading()
|
const loading = setupLoadingStateProvider()
|
||||||
loading.setEnabled(false)
|
loading.setEnabled(false)
|
||||||
loading.startLoading()
|
let initialLoadToken = loading.begin()
|
||||||
|
let routerToken = null
|
||||||
|
let suspenseToken = null
|
||||||
|
|
||||||
let suspensePending = false
|
let suspensePending = false
|
||||||
|
|
||||||
@@ -435,7 +438,8 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
|
|||||||
|
|
||||||
router.beforeEach(() => {
|
router.beforeEach(() => {
|
||||||
suspensePending = false
|
suspensePending = false
|
||||||
loading.startLoading()
|
if (routerToken) loading.end(routerToken)
|
||||||
|
routerToken = loading.begin()
|
||||||
})
|
})
|
||||||
router.afterEach((to, from, failure) => {
|
router.afterEach((to, from, failure) => {
|
||||||
trackEvent('PageView', {
|
trackEvent('PageView', {
|
||||||
@@ -445,11 +449,83 @@ router.afterEach((to, from, failure) => {
|
|||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!suspensePending && stateInitialized.value) {
|
if (!suspensePending && stateInitialized.value) {
|
||||||
loading.stopLoading()
|
if (initialLoadToken) {
|
||||||
|
loading.end(initialLoadToken)
|
||||||
|
initialLoadToken = null
|
||||||
|
}
|
||||||
|
if (routerToken) {
|
||||||
|
loading.end(routerToken)
|
||||||
|
routerToken = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 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 error = useError()
|
||||||
const errorModal = ref()
|
const errorModal = ref()
|
||||||
const minecraftAuthErrorModal = ref()
|
const minecraftAuthErrorModal = ref()
|
||||||
@@ -1236,7 +1312,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))',
|
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ModrinthLoadingIndicator />
|
<LoadingBar position="absolute" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="themeStore.featureFlags.page_path"
|
v-if="themeStore.featureFlags.page_path"
|
||||||
@@ -1272,19 +1348,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
</Admonition>
|
</Admonition>
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<template v-if="Component">
|
<template v-if="Component">
|
||||||
<Suspense
|
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||||
@pending="
|
|
||||||
() => {
|
|
||||||
suspensePending = true
|
|
||||||
loading.startLoading()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
@resolve="
|
|
||||||
() => {
|
|
||||||
loading.stopLoading()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<component :is="Component"></component>
|
<component :is="Component"></component>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { injectLoadingState } from '@modrinth/ui'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import { loading_listener } from '@/helpers/events.js'
|
import { loading_listener } from '@/helpers/events.js'
|
||||||
import { useLoading } from '@/store/loading.js'
|
|
||||||
|
|
||||||
const doneLoading = ref(false)
|
const doneLoading = ref(false)
|
||||||
const loadingProgress = ref(0)
|
const loadingProgress = ref(0)
|
||||||
@@ -91,20 +91,20 @@ const message = ref()
|
|||||||
const MIN_DISPLAY_MS = 500
|
const MIN_DISPLAY_MS = 500
|
||||||
const mountedAt = Date.now()
|
const mountedAt = Date.now()
|
||||||
|
|
||||||
const loading = useLoading()
|
const loading = injectLoadingState()
|
||||||
|
|
||||||
function onAfterLeave() {
|
function onAfterLeave() {
|
||||||
loading.setEnabled(true)
|
loading.setEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
loading,
|
[loading.barEnabled, loading.pending],
|
||||||
(newValue) => {
|
([barEnabled, pending]) => {
|
||||||
if (newValue.barEnabled) {
|
if (barEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.loading) {
|
if (pending) {
|
||||||
loadingProgress.value = 0
|
loadingProgress.value = 0
|
||||||
fakeLoadingIncrease()
|
fakeLoadingIncrease()
|
||||||
return
|
return
|
||||||
@@ -114,7 +114,7 @@ watch(
|
|||||||
const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
|
const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (loading.loading) {
|
if (loading.pending.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doneLoading.value = true
|
doneLoading.value = true
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -35,9 +35,6 @@
|
|||||||
@reinstall="onReinstall"
|
@reinstall="onReinstall"
|
||||||
@reinstall-failed="onReinstallFailed"
|
@reinstall-failed="onReinstallFailed"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</template>
|
</template>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
@@ -48,8 +45,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||||
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
|
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
@@ -64,6 +61,8 @@ import { useTheming } from '@/store/theme'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = injectAuth()
|
const auth = injectAuth()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
|
|
||||||
@@ -72,6 +71,18 @@ const serverId = computed(() => {
|
|||||||
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
|
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({
|
const { data: serverData } = useQuery({
|
||||||
queryKey: computed(() => ['servers', 'detail', serverId.value]),
|
queryKey: computed(() => ['servers', 'detail', serverId.value]),
|
||||||
queryFn: () => null as unknown as Archon.Servers.v0.Server,
|
queryFn: () => null as unknown as Archon.Servers.v0.Server,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FilePageLayout,
|
FilePageLayout,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
provideFileManager,
|
provideFileManager,
|
||||||
|
ReadyTransition,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -54,6 +55,8 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
const instanceRoot = ref('')
|
const instanceRoot = ref('')
|
||||||
const items = ref<FileItem[]>([])
|
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 loading = ref(true)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
const currentPath = ref('')
|
const currentPath = ref('')
|
||||||
@@ -123,6 +126,7 @@ async function refresh() {
|
|||||||
items.value = []
|
items.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
firstPaintPending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +309,7 @@ watch(
|
|||||||
() => props.instance.path,
|
() => props.instance.path,
|
||||||
async () => {
|
async () => {
|
||||||
debug('watch instance.path: changed to', props.instance.path)
|
debug('watch instance.path: changed to', props.instance.path)
|
||||||
|
firstPaintPending.value = true
|
||||||
instanceRoot.value = await get_full_path(props.instance.path)
|
instanceRoot.value = await get_full_path(props.instance.path)
|
||||||
currentPath.value = ''
|
currentPath.value = ''
|
||||||
await refresh()
|
await refresh()
|
||||||
@@ -341,5 +346,7 @@ provideFileManager({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FilePageLayout :show-refresh-button="true" />
|
<ReadyTransition :pending="firstPaintPending">
|
||||||
|
<FilePageLayout :show-refresh-button="true" />
|
||||||
|
</ReadyTransition>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -218,11 +218,7 @@
|
|||||||
:key="instance.path"
|
:key="instance.path"
|
||||||
>
|
>
|
||||||
<template v-if="Component">
|
<template v-if="Component">
|
||||||
<Suspense
|
<Suspense :key="instance.path">
|
||||||
:key="instance.path"
|
|
||||||
@pending="loadingBar.startLoading()"
|
|
||||||
@resolve="loadingBar.stopLoading()"
|
|
||||||
>
|
|
||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
:instance="instance"
|
:instance="instance"
|
||||||
@@ -235,9 +231,6 @@
|
|||||||
@play="updatePlayState"
|
@play="updatePlayState"
|
||||||
@stop="() => stopInstance('InstanceSubpage')"
|
@stop="() => stopInstance('InstanceSubpage')"
|
||||||
></component>
|
></component>
|
||||||
<template #fallback>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</template>
|
</template>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
@@ -296,7 +289,6 @@ import {
|
|||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
ContentPageHeader,
|
ContentPageHeader,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
LoadingIndicator,
|
|
||||||
NavTabs,
|
NavTabs,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
ServerOnlinePlayers,
|
ServerOnlinePlayers,
|
||||||
@@ -304,6 +296,7 @@ import {
|
|||||||
ServerRecentPlays,
|
ServerRecentPlays,
|
||||||
ServerRegion,
|
ServerRegion,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import duration from 'dayjs/plugin/duration'
|
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 { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
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 { injectServerInstall } from '@/providers/server-install'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { useBreadcrumbs, useLoading } from '@/store/state'
|
import { useBreadcrumbs } from '@/store/state'
|
||||||
|
|
||||||
dayjs.extend(duration)
|
dayjs.extend(duration)
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { playServerProject } = injectServerInstall()
|
const { playServerProject } = injectServerInstall()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -392,6 +386,14 @@ async function fetchInstance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchDeferredData()
|
fetchDeferredData()
|
||||||
|
|
||||||
|
if (instance.value) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['worlds', instance.value.path],
|
||||||
|
queryFn: () => refreshWorlds(instance.value!.path),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchDeferredData() {
|
function fetchDeferredData() {
|
||||||
@@ -471,8 +473,6 @@ if (instance.value) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingBar = useLoading()
|
|
||||||
|
|
||||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||||
|
|
||||||
const startInstance = async (context: string) => {
|
const startInstance = async (context: string) => {
|
||||||
|
|||||||
@@ -1,65 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<ContentPageLayout>
|
<ReadyTransition :pending="loading">
|
||||||
<template #modals>
|
<ContentPageLayout>
|
||||||
<ShareModalWrapper
|
<template #modals>
|
||||||
ref="shareModal"
|
<ShareModalWrapper
|
||||||
:share-title="formatMessage(messages.shareTitle)"
|
ref="shareModal"
|
||||||
:share-text="formatMessage(messages.shareText)"
|
:share-title="formatMessage(messages.shareTitle)"
|
||||||
:open-in-new-tab="false"
|
:share-text="formatMessage(messages.shareText)"
|
||||||
/>
|
:open-in-new-tab="false"
|
||||||
<ModpackContentModal
|
/>
|
||||||
ref="modpackContentModal"
|
<ModpackContentModal
|
||||||
:modpack-name="linkedModpackProject?.title"
|
ref="modpackContentModal"
|
||||||
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
|
:modpack-name="linkedModpackProject?.title"
|
||||||
:enable-toggle="!props.isServerInstance"
|
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
|
||||||
:get-overflow-options="getOverflowOptions"
|
:enable-toggle="!props.isServerInstance"
|
||||||
:switch-version="handleSwitchVersion"
|
:get-overflow-options="getOverflowOptions"
|
||||||
@update:enabled="handleModpackContentToggle"
|
:switch-version="handleSwitchVersion"
|
||||||
@bulk:enable="handleModpackContentBulkToggle"
|
@update:enabled="handleModpackContentToggle"
|
||||||
@bulk:disable="handleModpackContentBulkToggle"
|
@bulk:enable="handleModpackContentBulkToggle"
|
||||||
/>
|
@bulk:disable="handleModpackContentBulkToggle"
|
||||||
<ConfirmModpackUpdateModal
|
/>
|
||||||
ref="modpackUpdateConfirmModal"
|
<ConfirmModpackUpdateModal
|
||||||
:downgrade="isModpackUpdateDowngrade"
|
ref="modpackUpdateConfirmModal"
|
||||||
:backup-tip="
|
:downgrade="isModpackUpdateDowngrade"
|
||||||
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
|
:backup-tip="
|
||||||
.filter(Boolean)
|
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
|
||||||
.join(' ')
|
.filter(Boolean)
|
||||||
"
|
.join(' ')
|
||||||
@confirm="handleModpackUpdateConfirm"
|
"
|
||||||
@cancel="handleModpackUpdateCancel"
|
@confirm="handleModpackUpdateConfirm"
|
||||||
/>
|
@cancel="handleModpackUpdateCancel"
|
||||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
/>
|
||||||
<ContentUpdaterModal
|
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||||
v-if="updatingProject || updatingModpack"
|
<ContentUpdaterModal
|
||||||
ref="contentUpdaterModal"
|
v-if="updatingProject || updatingModpack"
|
||||||
:versions="updatingProjectVersions"
|
ref="contentUpdaterModal"
|
||||||
:current-game-version="instance.game_version"
|
:versions="updatingProjectVersions"
|
||||||
:current-loader="instance.loader"
|
:current-game-version="instance.game_version"
|
||||||
:current-version-id="
|
:current-loader="instance.loader"
|
||||||
updatingModpack
|
:current-version-id="
|
||||||
? (instance.linked_data?.version_id ?? '')
|
updatingModpack
|
||||||
: (updatingProject?.version?.id ?? '')
|
? (instance.linked_data?.version_id ?? '')
|
||||||
"
|
: (updatingProject?.version?.id ?? '')
|
||||||
:is-app="true"
|
"
|
||||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
:is-app="true"
|
||||||
:project-icon-url="
|
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||||
updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
|
:project-icon-url="
|
||||||
"
|
updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
|
||||||
:project-name="
|
"
|
||||||
updatingModpack
|
:project-name="
|
||||||
? (linkedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
|
updatingModpack
|
||||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
? (linkedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
|
||||||
"
|
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||||
:loading="loadingVersions"
|
"
|
||||||
:loading-changelog="loadingChangelog"
|
:loading="loadingVersions"
|
||||||
@update="handleModalUpdate"
|
:loading-changelog="loadingChangelog"
|
||||||
@cancel="resetUpdateState"
|
@update="handleModalUpdate"
|
||||||
@version-select="handleVersionSelect"
|
@cancel="resetUpdateState"
|
||||||
@version-hover="handleVersionHover"
|
@version-select="handleVersionSelect"
|
||||||
/>
|
@version-hover="handleVersionHover"
|
||||||
</template>
|
/>
|
||||||
</ContentPageLayout>
|
</template>
|
||||||
|
</ContentPageLayout>
|
||||||
|
</ReadyTransition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -82,6 +84,7 @@ import {
|
|||||||
type OverflowMenuOption,
|
type OverflowMenuOption,
|
||||||
provideAppBackup,
|
provideAppBackup,
|
||||||
provideContentManager,
|
provideContentManager,
|
||||||
|
ReadyTransition,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
|||||||
@@ -37,22 +37,109 @@
|
|||||||
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
|
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
|
||||||
@proceed="proceedDeleteWorld"
|
@proceed="proceedDeleteWorld"
|
||||||
/>
|
/>
|
||||||
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
<ReadyTransition :pending="worldsReadyPending">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
||||||
<StyledInput
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
v-model="searchFilter"
|
<StyledInput
|
||||||
:icon="SearchIcon"
|
v-model="searchFilter"
|
||||||
type="text"
|
:icon="SearchIcon"
|
||||||
autocomplete="off"
|
type="text"
|
||||||
:spellcheck="false"
|
autocomplete="off"
|
||||||
input-class="!h-10"
|
:spellcheck="false"
|
||||||
wrapper-class="flex-1 min-w-0"
|
input-class="!h-10"
|
||||||
clearable
|
wrapper-class="flex-1 min-w-0"
|
||||||
:placeholder="
|
clearable
|
||||||
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
:placeholder="
|
||||||
"
|
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
||||||
/>
|
"
|
||||||
<div class="flex gap-2">
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled type="outlined">
|
||||||
|
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||||
|
<PlusIcon class="size-5" />
|
||||||
|
{{ formatMessage(messages.addServer) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button
|
||||||
|
class="!h-10 flex items-center gap-2"
|
||||||
|
@click="
|
||||||
|
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CompassIcon class="size-5" />
|
||||||
|
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<FilterIcon class="size-5 text-secondary" />
|
||||||
|
<button
|
||||||
|
:class="filterPillClass(selectedFilters.length === 0)"
|
||||||
|
@click="selectedFilters = []"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.allProjectType) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in filterOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||||
|
@click="toggleFilter(option.id)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||||
|
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||||
|
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
||||||
|
{{ formatMessage(commonMessages.refreshButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<WorldItem
|
||||||
|
v-for="world in filteredWorlds"
|
||||||
|
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||||
|
:world="world"
|
||||||
|
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
|
||||||
|
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||||
|
:supports-server-quick-play="supportsServerQuickPlay"
|
||||||
|
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||||
|
:current-protocol="protocolVersion"
|
||||||
|
:playing-instance="playing"
|
||||||
|
:playing-world="worldsMatch(world, worldPlaying)"
|
||||||
|
:starting-instance="startingInstance"
|
||||||
|
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||||
|
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||||
|
:rendered-motd="
|
||||||
|
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||||
|
"
|
||||||
|
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||||
|
@play="() => joinWorld(world)"
|
||||||
|
@stop="() => emit('stop')"
|
||||||
|
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||||
|
@edit="
|
||||||
|
() =>
|
||||||
|
world.type === 'singleplayer'
|
||||||
|
? editWorldModal?.show(world)
|
||||||
|
: isManagedServerWorld(world)
|
||||||
|
? undefined
|
||||||
|
: editServerModal?.show(world)
|
||||||
|
"
|
||||||
|
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
|
||||||
|
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
type="empty-inbox"
|
||||||
|
:heading="formatMessage(messages.noWorldsHeading)"
|
||||||
|
:description="formatMessage(messages.noWorldsDescription)"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
<ButtonStyled type="outlined">
|
<ButtonStyled type="outlined">
|
||||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
||||||
<PlusIcon class="size-5" />
|
<PlusIcon class="size-5" />
|
||||||
@@ -70,94 +157,9 @@
|
|||||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
</ReadyTransition>
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
<FilterIcon class="size-5 text-secondary" />
|
|
||||||
<button
|
|
||||||
:class="filterPillClass(selectedFilters.length === 0)"
|
|
||||||
@click="selectedFilters = []"
|
|
||||||
>
|
|
||||||
{{ formatMessage(commonMessages.allProjectType) }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="option in filterOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:class="filterPillClass(selectedFilters.includes(option.id))"
|
|
||||||
@click="toggleFilter(option.id)"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
|
||||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
|
||||||
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
|
||||||
{{ formatMessage(commonMessages.refreshButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col w-full gap-2">
|
|
||||||
<WorldItem
|
|
||||||
v-for="world in filteredWorlds"
|
|
||||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
|
||||||
:world="world"
|
|
||||||
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
|
|
||||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
|
||||||
:supports-server-quick-play="supportsServerQuickPlay"
|
|
||||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
|
||||||
:current-protocol="protocolVersion"
|
|
||||||
:playing-instance="playing"
|
|
||||||
:playing-world="worldsMatch(world, worldPlaying)"
|
|
||||||
:starting-instance="startingInstance"
|
|
||||||
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
|
||||||
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
|
||||||
:rendered-motd="
|
|
||||||
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
|
||||||
"
|
|
||||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
|
||||||
@play="() => joinWorld(world)"
|
|
||||||
@stop="() => emit('stop')"
|
|
||||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
|
||||||
@edit="
|
|
||||||
() =>
|
|
||||||
world.type === 'singleplayer'
|
|
||||||
? editWorldModal?.show(world)
|
|
||||||
: isManagedServerWorld(world)
|
|
||||||
? undefined
|
|
||||||
: editServerModal?.show(world)
|
|
||||||
"
|
|
||||||
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
|
|
||||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EmptyState
|
|
||||||
v-else
|
|
||||||
type="empty-inbox"
|
|
||||||
:heading="formatMessage(messages.noWorldsHeading)"
|
|
||||||
:description="formatMessage(messages.noWorldsDescription)"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
|
|
||||||
<PlusIcon class="size-5" />
|
|
||||||
{{ formatMessage(messages.addServer) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
|
||||||
class="!h-10 flex items-center gap-2"
|
|
||||||
@click="
|
|
||||||
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<CompassIcon class="size-5" />
|
|
||||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</EmptyState>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
|
import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
|
||||||
@@ -169,11 +171,14 @@ import {
|
|||||||
GAME_MODES,
|
GAME_MODES,
|
||||||
type GameVersion,
|
type GameVersion,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
|
ReadyTransition,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
|
useReadyState,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { platform } from '@tauri-apps/plugin-os'
|
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 { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
@@ -344,11 +349,21 @@ function toggleFilter(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const refreshingAll = ref(false)
|
const refreshingAll = ref(false)
|
||||||
const hadNoWorlds = ref(true)
|
const hadNoWorlds = ref(true)
|
||||||
const startingInstance = ref(false)
|
const startingInstance = ref(false)
|
||||||
const worldPlaying = ref<World>()
|
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 worlds = ref<World[]>([])
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
|
||||||
@@ -358,6 +373,26 @@ const isLinux = platform() === 'linux'
|
|||||||
const linuxRefreshCount = ref(0)
|
const linuxRefreshCount = ref(0)
|
||||||
|
|
||||||
const protocolVersion = ref<ProtocolVersion | null>(null)
|
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 managedServerName = ref<string | null>(null)
|
||||||
const managedServerAddress = ref<string | null>(null)
|
const managedServerAddress = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -385,8 +420,8 @@ async function refreshManagedServerMetadata() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [project, projectV3] = await Promise.all([
|
const [project, projectV3] = await Promise.all([
|
||||||
get_project(projectId, 'bypass'),
|
get_project(projectId),
|
||||||
get_project_v3(projectId, 'bypass'),
|
get_project_v3(projectId),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (projectV3?.minecraft_server == null) {
|
if (projectV3?.minecraft_server == null) {
|
||||||
@@ -422,27 +457,40 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
let unlistenProfile: (() => void) | null = null
|
||||||
profile_listener(async (e: ProfileEvent) => {
|
let worldsTabAlive = true
|
||||||
if (e.profile_path_id !== instance.value.path) return
|
|
||||||
|
|
||||||
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
|
async function initWorldsTab() {
|
||||||
|
const [_unlistenProfile, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||||
|
profile_listener(async (e: ProfileEvent) => {
|
||||||
|
if (e.profile_path_id !== instance.value.path) return
|
||||||
|
|
||||||
if (e.event === 'servers_updated') {
|
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
|
||||||
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
|
||||||
if (isLinux) linuxRefreshCount.value++
|
|
||||||
|
|
||||||
await refreshAllWorlds()
|
if (e.event === 'servers_updated') {
|
||||||
}
|
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
||||||
|
if (isLinux) linuxRefreshCount.value++
|
||||||
|
|
||||||
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
await refreshAllWorlds()
|
||||||
}),
|
}
|
||||||
refreshAllWorlds(),
|
|
||||||
get_profile_protocol_version(instance.value.path).catch(() => null),
|
|
||||||
get_game_versions().catch(() => [] as GameVersion[]),
|
|
||||||
])
|
|
||||||
|
|
||||||
protocolVersion.value = resolvedProtocolVersion
|
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
||||||
|
}),
|
||||||
|
get_profile_protocol_version(instance.value.path).catch(() => null),
|
||||||
|
get_game_versions().catch(() => [] as GameVersion[]),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!worldsTabAlive) {
|
||||||
|
_unlistenProfile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unlistenProfile = _unlistenProfile
|
||||||
|
protocolVersion.value = resolvedProtocolVersion
|
||||||
|
gameVersions.value = resolvedGameVersions
|
||||||
|
}
|
||||||
|
|
||||||
|
await initWorldsTab()
|
||||||
|
|
||||||
async function refreshServer(address: string) {
|
async function refreshServer(address: string) {
|
||||||
if (!serverData.value[address]) {
|
if (!serverData.value[address]) {
|
||||||
@@ -458,26 +506,10 @@ async function refreshAllWorlds() {
|
|||||||
console.log(`Already refreshing, cancelling refresh.`)
|
console.log(`Already refreshing, cancelling refresh.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshManagedServerMetadata()
|
|
||||||
|
|
||||||
refreshingAll.value = true
|
refreshingAll.value = true
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.path] })
|
||||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
refreshingAll.value = false
|
||||||
() => (refreshingAll.value = false),
|
|
||||||
)
|
|
||||||
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
|
||||||
|
|
||||||
const hasNoWorlds = worlds.value.length === 0
|
|
||||||
|
|
||||||
if (hadNoWorlds.value && hasNoWorlds) {
|
|
||||||
setTimeout(() => {
|
|
||||||
refreshingAll.value = false
|
|
||||||
}, 1000)
|
|
||||||
} else {
|
|
||||||
refreshingAll.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
hadNoWorlds.value = hasNoWorlds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addServer(server: ServerWorld) {
|
async function addServer(server: ServerWorld) {
|
||||||
@@ -592,14 +624,6 @@ function worldsMatch(world: World, other: World | undefined) {
|
|||||||
return false
|
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 dedupedWorlds = computed(() => {
|
||||||
const visibleWorlds: World[] = []
|
const visibleWorlds: World[] = []
|
||||||
const serverIndexByDomain = new Map<string, number>()
|
const serverIndexByDomain = new Map<string, number>()
|
||||||
@@ -749,7 +773,8 @@ async function proceedDeleteWorld() {
|
|||||||
worldToDelete.value = undefined
|
worldToDelete.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onBeforeUnmount(() => {
|
||||||
unlistenProfile()
|
worldsTabAlive = false
|
||||||
|
unlistenProfile?.()
|
||||||
})
|
})
|
||||||
</script>
|
</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 { useBreadcrumbs } from './breadcrumbs'
|
||||||
import { useLoading } from './loading'
|
|
||||||
import { useTheming } from './theme.ts'
|
import { useTheming } from './theme.ts'
|
||||||
|
|
||||||
export { useBreadcrumbs, useLoading, useTheming }
|
export { useBreadcrumbs, useTheming }
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<ModrinthLoadingIndicator />
|
<LoadingBar />
|
||||||
<NotificationPanel />
|
<NotificationPanel />
|
||||||
<I18nDebugPanel />
|
<I18nDebugPanel />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<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'
|
import { setupProviders } from '~/providers/setup.ts'
|
||||||
|
|
||||||
const auth = await useAuth()
|
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>
|
<template>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<ModrinthLoadingIndicator />
|
<LoadingBar />
|
||||||
<NotificationPanel />
|
<NotificationPanel />
|
||||||
<div class="main experimental-styles-within">
|
<div class="main experimental-styles-within">
|
||||||
<div v-if="is404" class="error-graphic">
|
<div v-if="is404" class="error-graphic">
|
||||||
@@ -55,6 +55,7 @@ import { SadRinthbot } from '@modrinth/assets'
|
|||||||
import {
|
import {
|
||||||
defineMessage,
|
defineMessage,
|
||||||
IntlFormatted,
|
IntlFormatted,
|
||||||
|
LoadingBar,
|
||||||
normalizeChildren,
|
normalizeChildren,
|
||||||
NotificationPanel,
|
NotificationPanel,
|
||||||
provideModrinthClient,
|
provideModrinthClient,
|
||||||
@@ -65,14 +66,15 @@ import {
|
|||||||
|
|
||||||
import Logo404 from '~/assets/images/404.svg'
|
import Logo404 from '~/assets/images/404.svg'
|
||||||
|
|
||||||
import ModrinthLoadingIndicator from './components/ui/modrinth-loading-indicator.ts'
|
|
||||||
import { createModrinthClient } from './helpers/api.ts'
|
import { createModrinthClient } from './helpers/api.ts'
|
||||||
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
|
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
|
||||||
|
import { setupLoadingStateProvider } from './providers/setup/loading-state.ts'
|
||||||
|
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
provideNotificationManager(new FrontendNotificationManager())
|
provideNotificationManager(new FrontendNotificationManager())
|
||||||
|
setupLoadingStateProvider()
|
||||||
|
|
||||||
const client = createModrinthClient(auth.value, {
|
const client = createModrinthClient(auth.value, {
|
||||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { reloadNuxtApp } from '#app'
|
||||||
import { products } from '~/generated/state.json'
|
import { products } from '~/generated/state.json'
|
||||||
@@ -48,6 +49,21 @@ const router = useRouter()
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const serverId = route.params.id as string
|
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 {
|
const auth = (await useAuth()) as unknown as {
|
||||||
value: { user: { id: string; username: string; email: string; created: string } }
|
value: { user: { id: string; username: string; email: string; created: string } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<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()
|
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({
|
useHead({
|
||||||
title: `Backups - ${server.value?.name ?? 'Server'} - Modrinth`,
|
title: `Backups - ${server.value?.name ?? 'Server'} - Modrinth`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<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({
|
useHead({
|
||||||
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
|
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<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()
|
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({
|
useHead({
|
||||||
title: computed(() => `Files - ${server.value?.name ?? 'Server'} - Modrinth`),
|
title: computed(() => `Files - ${server.value?.name ?? 'Server'} - Modrinth`),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { provideNotificationManager } from '@modrinth/ui'
|
|||||||
import { FrontendNotificationManager } from './frontend-notifications'
|
import { FrontendNotificationManager } from './frontend-notifications'
|
||||||
import { setupAuthProvider } from './setup/auth'
|
import { setupAuthProvider } from './setup/auth'
|
||||||
import { setupFilePickerProvider } from './setup/file-picker'
|
import { setupFilePickerProvider } from './setup/file-picker'
|
||||||
|
import { setupLoadingStateProvider } from './setup/loading-state'
|
||||||
import { setupModrinthClientProvider } from './setup/modrinth-client'
|
import { setupModrinthClientProvider } from './setup/modrinth-client'
|
||||||
import { setupPageContextProvider } from './setup/page-context'
|
import { setupPageContextProvider } from './setup/page-context'
|
||||||
import { setupTagsProvider } from './setup/tags'
|
import { setupTagsProvider } from './setup/tags'
|
||||||
@@ -15,4 +16,5 @@ export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
|
|||||||
setupTagsProvider()
|
setupTagsProvider()
|
||||||
setupFilePickerProvider()
|
setupFilePickerProvider()
|
||||||
setupPageContextProvider()
|
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::sync::LazyLock;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -284,15 +285,24 @@ async fn get_singleplayer_worlds_in_profile(
|
|||||||
if !saves_dir.exists() {
|
if !saves_dir.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut saves_dir = io::read_dir(saves_dir).await?;
|
let mut entries = io::read_dir(&saves_dir).await?;
|
||||||
while let Some(world_dir) = saves_dir.next_entry().await? {
|
let mut tasks = JoinSet::new();
|
||||||
|
while let Some(world_dir) = entries.next_entry().await? {
|
||||||
let world_path = world_dir.path();
|
let world_path = world_dir.path();
|
||||||
let level_dat_path = world_path.join("level.dat");
|
if !world_path.join("level.dat").exists() {
|
||||||
if !level_dat_path.exists() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(world) = read_singleplayer_world(world_path).await {
|
tasks.spawn(read_singleplayer_world(world_path));
|
||||||
worlds.push(world);
|
}
|
||||||
|
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,
|
world_path: PathBuf,
|
||||||
locked: bool,
|
locked: bool,
|
||||||
) -> Result<World> {
|
) -> Result<World> {
|
||||||
#[derive(Deserialize, Debug)]
|
let raw = io::read(world_path.join("level.dat")).await?;
|
||||||
#[serde(rename_all = "PascalCase")]
|
let (root, _) = quartz_nbt::io::read_nbt(
|
||||||
struct LevelDataRoot {
|
&mut Cursor::new(raw),
|
||||||
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,
|
|
||||||
quartz_nbt::io::Flavor::GzCompressed,
|
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,
|
0 => SingleplayerGameMode::Survival,
|
||||||
1 => SingleplayerGameMode::Creative,
|
1 => SingleplayerGameMode::Creative,
|
||||||
2 => SingleplayerGameMode::Adventure,
|
2 => SingleplayerGameMode::Adventure,
|
||||||
@@ -371,9 +381,9 @@ async fn read_singleplayer_world_maybe_locked(
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(World {
|
Ok(World {
|
||||||
name: level_data.level_name,
|
name: level_name,
|
||||||
last_played: Utc.timestamp_millis_opt(level_data.last_played).single(),
|
last_played: Utc.timestamp_millis_opt(last_played).single(),
|
||||||
icon: icon.map(Either::Left),
|
icon,
|
||||||
display_status: DisplayStatus::Normal,
|
display_status: DisplayStatus::Normal,
|
||||||
details: WorldDetails::Singleplayer {
|
details: WorldDetails::Singleplayer {
|
||||||
path: world_path
|
path: world_path
|
||||||
@@ -382,7 +392,7 @@ async fn read_singleplayer_world_maybe_locked(
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
game_mode,
|
game_mode,
|
||||||
hardcore: level_data.hardcore,
|
hardcore,
|
||||||
locked,
|
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 { default as IntlFormatted } from './IntlFormatted.vue'
|
||||||
export type { JoinedButtonAction } from './JoinedButtons.vue'
|
export type { JoinedButtonAction } from './JoinedButtons.vue'
|
||||||
export { default as JoinedButtons } 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 LoadingIndicator } from './LoadingIndicator.vue'
|
||||||
export { default as ManySelect } from './ManySelect.vue'
|
export { default as ManySelect } from './ManySelect.vue'
|
||||||
export { default as MarkdownEditor } from './MarkdownEditor.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 ProgressSpinner } from './ProgressSpinner.vue'
|
||||||
export { default as RadialHeader } from './RadialHeader.vue'
|
export { default as RadialHeader } from './RadialHeader.vue'
|
||||||
export { default as RadioButtons } from './RadioButtons.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 ScrollablePanel } from './ScrollablePanel.vue'
|
||||||
export { default as ServerNotice } from './ServerNotice.vue'
|
export { default as ServerNotice } from './ServerNotice.vue'
|
||||||
export { default as SettingsLabel } from './SettingsLabel.vue'
|
export { default as SettingsLabel } from './SettingsLabel.vue'
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export * from './server-console'
|
|||||||
export * from './server-manage-core-runtime'
|
export * from './server-manage-core-runtime'
|
||||||
export * from './sticky-observer'
|
export * from './sticky-observer'
|
||||||
export * from './terminal'
|
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-image'
|
||||||
export * from './use-server-project'
|
export * from './use-server-project'
|
||||||
export * from './virtual-scroll'
|
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,
|
RefreshCwIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
SpinnerIcon,
|
|
||||||
TextCursorInputIcon,
|
TextCursorInputIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
@@ -504,304 +503,300 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 pb-6">
|
<div class="flex flex-col gap-4 pb-6">
|
||||||
<div
|
<template v-if="!ctx.loading.value">
|
||||||
v-if="ctx.loading.value"
|
<div
|
||||||
role="status"
|
v-if="ctx.error.value"
|
||||||
aria-live="polite"
|
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||||
class="flex min-h-[50vh] w-full flex-col items-center justify-center gap-2 text-center text-secondary"
|
|
||||||
>
|
|
||||||
<SpinnerIcon class="animate-spin" />
|
|
||||||
{{ formatMessage(messages.loadingContent) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="ctx.error.value"
|
|
||||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
|
||||||
>
|
|
||||||
<div class="universal-card flex flex-col items-center gap-4 p-6">
|
|
||||||
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
|
|
||||||
<p class="text-secondary">{{ ctx.error.value.message }}</p>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
|
|
||||||
<template #header>{{ ctx.busyMessage.value }}</template>
|
|
||||||
{{ formatMessage(messages.busyDescription) }}
|
|
||||||
</Admonition>
|
|
||||||
|
|
||||||
<ContentModpackCard
|
|
||||||
v-if="ctx.modpack.value"
|
|
||||||
:project="ctx.modpack.value.project"
|
|
||||||
:project-link="ctx.modpack.value.projectLink"
|
|
||||||
:version="ctx.modpack.value.version"
|
|
||||||
:version-link="ctx.modpack.value.versionLink"
|
|
||||||
:owner="ctx.modpack.value.owner"
|
|
||||||
:categories="ctx.modpack.value.categories"
|
|
||||||
:has-update="ctx.modpack.value.hasUpdate"
|
|
||||||
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
|
||||||
:disabled-text="
|
|
||||||
ctx.modpack.value.disabledText ??
|
|
||||||
ctx.busyMessage?.value ??
|
|
||||||
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
|
||||||
"
|
|
||||||
:show-content-hint="
|
|
||||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
|
||||||
"
|
|
||||||
v-on="{
|
|
||||||
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
|
||||||
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
|
||||||
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
|
||||||
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
|
||||||
}"
|
|
||||||
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
|
||||||
enter-from-class="opacity-0 max-h-0"
|
|
||||||
enter-to-class="opacity-100 max-h-40"
|
|
||||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
|
||||||
leave-from-class="opacity-100 max-h-40"
|
|
||||||
leave-to-class="opacity-0 max-h-0"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
|
<div class="universal-card flex flex-col items-center gap-4 p-6">
|
||||||
<template #icon>
|
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
|
||||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
<p class="text-secondary">{{ ctx.error.value.message }}</p>
|
||||||
</template>
|
<ButtonStyled color="brand">
|
||||||
<template #header>
|
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
|
||||||
|
<template #header>{{ ctx.busyMessage.value }}</template>
|
||||||
|
{{ formatMessage(messages.busyDescription) }}
|
||||||
|
</Admonition>
|
||||||
|
|
||||||
|
<ContentModpackCard
|
||||||
|
v-if="ctx.modpack.value"
|
||||||
|
:project="ctx.modpack.value.project"
|
||||||
|
:project-link="ctx.modpack.value.projectLink"
|
||||||
|
:version="ctx.modpack.value.version"
|
||||||
|
:version-link="ctx.modpack.value.versionLink"
|
||||||
|
:owner="ctx.modpack.value.owner"
|
||||||
|
:categories="ctx.modpack.value.categories"
|
||||||
|
:has-update="ctx.modpack.value.hasUpdate"
|
||||||
|
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
||||||
|
:disabled-text="
|
||||||
|
ctx.modpack.value.disabledText ??
|
||||||
|
ctx.busyMessage?.value ??
|
||||||
|
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
||||||
|
"
|
||||||
|
:show-content-hint="
|
||||||
|
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||||
|
"
|
||||||
|
v-on="{
|
||||||
|
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
||||||
|
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
||||||
|
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
||||||
|
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
||||||
|
}"
|
||||||
|
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-40"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||||
|
leave-from-class="opacity-100 max-h-40"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Admonition
|
||||||
|
v-if="ctx.uploadState?.value?.isUploading"
|
||||||
|
type="info"
|
||||||
|
show-actions-underneath
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||||
|
</template>
|
||||||
|
<template #header>
|
||||||
|
{{
|
||||||
|
formatMessage(messages.uploadingFiles, {
|
||||||
|
completed: ctx.uploadState?.value?.completedFiles ?? 0,
|
||||||
|
total: ctx.uploadState?.value?.totalFiles ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<span class="text-secondary">
|
||||||
|
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
|
||||||
|
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
|
||||||
|
Math.round(uploadOverallProgress * 100)
|
||||||
|
}}%)
|
||||||
|
</span>
|
||||||
|
<template #actions>
|
||||||
|
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||||
|
</template>
|
||||||
|
</Admonition>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<template v-if="ctx.items.value.length > 0">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.additionalContent) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<StyledInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
:spellcheck="false"
|
||||||
|
input-class="!h-10"
|
||||||
|
wrapper-class="flex-1 min-w-0"
|
||||||
|
clearable
|
||||||
|
:placeholder="
|
||||||
|
formatMessage(messages.searchPlaceholder, {
|
||||||
|
count: tableItems.length,
|
||||||
|
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button
|
||||||
|
v-tooltip="
|
||||||
|
ctx.busyMessage?.value ??
|
||||||
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||||
|
"
|
||||||
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||||
|
class="!h-10 flex items-center gap-2"
|
||||||
|
@click="ctx.browse"
|
||||||
|
>
|
||||||
|
<CompassIcon class="size-5" />
|
||||||
|
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled type="outlined">
|
||||||
|
<button
|
||||||
|
v-tooltip="
|
||||||
|
ctx.busyMessage?.value ??
|
||||||
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||||
|
"
|
||||||
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||||
|
class="!h-10 !border-button-bg !border-[1px]"
|
||||||
|
@click="ctx.uploadFiles"
|
||||||
|
>
|
||||||
|
<FolderOpenIcon class="size-5" />
|
||||||
|
{{ formatMessage(messages.uploadFiles) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="@container flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<FilterIcon class="size-5 text-secondary" />
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||||
|
:class="
|
||||||
|
selectedFilters.length === 0
|
||||||
|
? 'border-green bg-brand-highlight text-brand'
|
||||||
|
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||||
|
"
|
||||||
|
:aria-pressed="selectedFilters.length === 0"
|
||||||
|
@click="selectedFilters = []"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.allProjectType) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in filterOptions"
|
||||||
|
:key="option.id"
|
||||||
|
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||||
|
:class="
|
||||||
|
selectedFilters.includes(option.id)
|
||||||
|
? 'border-green bg-brand-highlight text-brand'
|
||||||
|
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||||
|
"
|
||||||
|
:aria-pressed="selectedFilters.includes(option.id)"
|
||||||
|
@click="toggleFilter(option.id)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
<div class="hidden @[900px]:block">
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button
|
||||||
|
:aria-label="
|
||||||
|
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||||
|
"
|
||||||
|
@click="cycleSortMode"
|
||||||
|
>
|
||||||
|
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||||
|
v-else-if="sortMode === 'date-added-newest'"
|
||||||
|
/><ClockArrowUpIcon
|
||||||
|
v-else-if="sortMode === 'date-added-oldest'"
|
||||||
|
/><ArrowDownAZIcon v-else />
|
||||||
|
{{ sortLabels[sortMode]() }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="@[900px]:hidden">
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button
|
||||||
|
:aria-label="
|
||||||
|
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||||
|
"
|
||||||
|
@click="cycleSortMode"
|
||||||
|
>
|
||||||
|
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||||
|
v-else-if="sortMode === 'date-added-newest'"
|
||||||
|
/><ClockArrowUpIcon
|
||||||
|
v-else-if="sortMode === 'date-added-oldest'"
|
||||||
|
/><ArrowDownAZIcon v-else />
|
||||||
|
{{ sortLabels[sortMode]() }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
|
||||||
|
color="green"
|
||||||
|
type="transparent"
|
||||||
|
color-fill="text"
|
||||||
|
hover-color-fill="background"
|
||||||
|
>
|
||||||
|
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
||||||
|
<DownloadIcon />
|
||||||
|
{{ formatMessage(messages.updateAll) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
||||||
|
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
||||||
|
{{ formatMessage(commonMessages.refreshButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContentCardTable
|
||||||
|
v-model:selected-ids="selectedIds"
|
||||||
|
:items="tableItems"
|
||||||
|
:show-selection="true"
|
||||||
|
@update:enabled="handleToggleEnabledById"
|
||||||
|
@delete="handleDeleteById"
|
||||||
|
@update="handleUpdateById"
|
||||||
|
@switch-version="handleSwitchVersionById"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<span>{{ formatMessage(messages.noContentFound) }}</span>
|
||||||
|
</template>
|
||||||
|
</ContentCardTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<EmptyState v-else type="empty-inbox">
|
||||||
|
<template #heading>
|
||||||
{{
|
{{
|
||||||
formatMessage(messages.uploadingFiles, {
|
formatMessage(
|
||||||
completed: ctx.uploadState?.value?.completedFiles ?? 0,
|
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
|
||||||
total: ctx.uploadState?.value?.totalFiles ?? 0,
|
)
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
<span class="text-secondary">
|
<template #description>
|
||||||
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
|
{{
|
||||||
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
|
ctx.modpack.value
|
||||||
Math.round(uploadOverallProgress * 100)
|
? formatMessage(messages.emptyModpackHint)
|
||||||
}}%)
|
: formatMessage(messages.emptyHint, {
|
||||||
</span>
|
contentType: `${ctx.contentTypeLabel.value}s`,
|
||||||
<template #actions>
|
})
|
||||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
}}
|
||||||
</template>
|
</template>
|
||||||
</Admonition>
|
<template #actions>
|
||||||
</Transition>
|
<ButtonStyled type="outlined">
|
||||||
|
|
||||||
<template v-if="ctx.items.value.length > 0">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.additionalContent) }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<StyledInput
|
|
||||||
v-model="searchQuery"
|
|
||||||
:icon="SearchIcon"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
:spellcheck="false"
|
|
||||||
input-class="!h-10"
|
|
||||||
wrapper-class="flex-1 min-w-0"
|
|
||||||
clearable
|
|
||||||
:placeholder="
|
|
||||||
formatMessage(messages.searchPlaceholder, {
|
|
||||||
count: tableItems.length,
|
|
||||||
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
ctx.busyMessage?.value ??
|
|
||||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
||||||
"
|
|
||||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
||||||
class="!h-10 flex items-center gap-2"
|
|
||||||
@click="ctx.browse"
|
|
||||||
>
|
|
||||||
<CompassIcon class="size-5" />
|
|
||||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
ctx.busyMessage?.value ??
|
|
||||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
||||||
"
|
|
||||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
||||||
class="!h-10 !border-button-bg !border-[1px]"
|
|
||||||
@click="ctx.uploadFiles"
|
|
||||||
>
|
|
||||||
<FolderOpenIcon class="size-5" />
|
|
||||||
{{ formatMessage(messages.uploadFiles) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="@container flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
<FilterIcon class="size-5 text-secondary" />
|
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
v-tooltip="
|
||||||
:class="
|
ctx.busyMessage?.value ??
|
||||||
selectedFilters.length === 0
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||||
? 'border-green bg-brand-highlight text-brand'
|
|
||||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
|
||||||
"
|
"
|
||||||
:aria-pressed="selectedFilters.length === 0"
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||||
@click="selectedFilters = []"
|
class="!h-10 !border-button-bg !border-[1px]"
|
||||||
|
@click="ctx.uploadFiles"
|
||||||
>
|
>
|
||||||
{{ formatMessage(commonMessages.allProjectType) }}
|
<FolderOpenIcon class="size-5" />
|
||||||
|
{{ formatMessage(messages.uploadFiles) }}
|
||||||
</button>
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
v-for="option in filterOptions"
|
v-tooltip="
|
||||||
:key="option.id"
|
ctx.busyMessage?.value ??
|
||||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||||
:class="
|
|
||||||
selectedFilters.includes(option.id)
|
|
||||||
? 'border-green bg-brand-highlight text-brand'
|
|
||||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
|
||||||
"
|
"
|
||||||
:aria-pressed="selectedFilters.includes(option.id)"
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||||
@click="toggleFilter(option.id)"
|
class="!h-10 flex items-center gap-2"
|
||||||
|
@click="ctx.browse"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<CompassIcon class="size-5" />
|
||||||
|
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="hidden @[900px]:block">
|
</ButtonStyled>
|
||||||
<ButtonStyled type="transparent">
|
</template>
|
||||||
<button
|
</EmptyState>
|
||||||
:aria-label="
|
|
||||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
|
||||||
"
|
|
||||||
@click="cycleSortMode"
|
|
||||||
>
|
|
||||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
|
||||||
v-else-if="sortMode === 'date-added-newest'"
|
|
||||||
/><ClockArrowUpIcon
|
|
||||||
v-else-if="sortMode === 'date-added-oldest'"
|
|
||||||
/><ArrowDownAZIcon v-else />
|
|
||||||
{{ sortLabels[sortMode]() }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="@[900px]:hidden">
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
:aria-label="
|
|
||||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
|
||||||
"
|
|
||||||
@click="cycleSortMode"
|
|
||||||
>
|
|
||||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
|
||||||
v-else-if="sortMode === 'date-added-newest'"
|
|
||||||
/><ClockArrowUpIcon
|
|
||||||
v-else-if="sortMode === 'date-added-oldest'"
|
|
||||||
/><ArrowDownAZIcon v-else />
|
|
||||||
{{ sortLabels[sortMode]() }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
|
|
||||||
color="green"
|
|
||||||
type="transparent"
|
|
||||||
color-fill="text"
|
|
||||||
hover-color-fill="background"
|
|
||||||
>
|
|
||||||
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
|
||||||
<DownloadIcon />
|
|
||||||
{{ formatMessage(messages.updateAll) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
|
||||||
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
|
||||||
{{ formatMessage(commonMessages.refreshButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContentCardTable
|
|
||||||
v-model:selected-ids="selectedIds"
|
|
||||||
:items="tableItems"
|
|
||||||
:show-selection="true"
|
|
||||||
@update:enabled="handleToggleEnabledById"
|
|
||||||
@delete="handleDeleteById"
|
|
||||||
@update="handleUpdateById"
|
|
||||||
@switch-version="handleSwitchVersionById"
|
|
||||||
>
|
|
||||||
<template #empty>
|
|
||||||
<span>{{ formatMessage(messages.noContentFound) }}</span>
|
|
||||||
</template>
|
|
||||||
</ContentCardTable>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<EmptyState v-else type="empty-inbox">
|
|
||||||
<template #heading>
|
|
||||||
{{
|
|
||||||
formatMessage(
|
|
||||||
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template #description>
|
|
||||||
{{
|
|
||||||
ctx.modpack.value
|
|
||||||
? formatMessage(messages.emptyModpackHint)
|
|
||||||
: formatMessage(messages.emptyHint, {
|
|
||||||
contentType: `${ctx.contentTypeLabel.value}s`,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled type="outlined">
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
ctx.busyMessage?.value ??
|
|
||||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
||||||
"
|
|
||||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
||||||
class="!h-10 !border-button-bg !border-[1px]"
|
|
||||||
@click="ctx.uploadFiles"
|
|
||||||
>
|
|
||||||
<FolderOpenIcon class="size-5" />
|
|
||||||
{{ formatMessage(messages.uploadFiles) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
ctx.busyMessage?.value ??
|
|
||||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
||||||
"
|
|
||||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
||||||
class="!h-10 flex items-center gap-2"
|
|
||||||
@click="ctx.browse"
|
|
||||||
>
|
|
||||||
<CompassIcon class="size-5" />
|
|
||||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</EmptyState>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ContentSelectionBar
|
<ContentSelectionBar
|
||||||
|
|||||||
@@ -31,180 +31,169 @@
|
|||||||
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||||
>
|
>
|
||||||
</FileContextMenu>
|
</FileContextMenu>
|
||||||
<Transition name="fade" mode="out-in">
|
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
|
||||||
<div
|
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
||||||
v-if="ctx.loading.value && items.length === 0"
|
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||||
key="loading"
|
{{ formatMessage(messages.busyWarning) }}
|
||||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
</Admonition>
|
||||||
>
|
<div class="relative flex w-full flex-col">
|
||||||
<SpinnerIcon class="animate-spin" />
|
<div class="relative isolate flex w-full flex-col gap-4">
|
||||||
{{ formatMessage(messages.loadingFiles) }}
|
<FileNavbar
|
||||||
</div>
|
:breadcrumbs="breadcrumbSegments"
|
||||||
|
:is-editing="isEditing"
|
||||||
|
:editing-file-name="ctx.editingFile.value?.name"
|
||||||
|
:editing-file-path="ctx.editingFile.value?.path"
|
||||||
|
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||||
|
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
||||||
|
:search-query="searchQuery"
|
||||||
|
:show-refresh-button="showRefreshButton"
|
||||||
|
:show-install-from-url="ctx.showInstallFromUrl"
|
||||||
|
:base-id="baseId"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:disabled-tooltip="busyTooltip"
|
||||||
|
@navigate="navigateToSegment"
|
||||||
|
@navigate-home="() => navigateToSegment(-1)"
|
||||||
|
@prefetch-home="handlePrefetchHome"
|
||||||
|
@update:search-query="searchQuery = $event"
|
||||||
|
@create="showCreateModal"
|
||||||
|
@upload="initiateFileUpload"
|
||||||
|
@upload-zip="() => {}"
|
||||||
|
@unzip-from-url="showUnzipFromUrlModal"
|
||||||
|
@refresh="ctx.refresh"
|
||||||
|
@share="() => fileEditorRef?.shareToMclogs()"
|
||||||
|
@find="() => fileEditorRef?.toggleFind()"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else key="content" class="contents">
|
<div v-if="!isEditing">
|
||||||
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
<FileUploadDragAndDrop
|
||||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
ref="fileUploadRef"
|
||||||
{{ formatMessage(messages.busyWarning) }}
|
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||||
</Admonition>
|
@files-dropped="handleDroppedFiles"
|
||||||
<div class="relative flex w-full flex-col">
|
|
||||||
<div class="relative isolate flex w-full flex-col gap-4">
|
|
||||||
<FileNavbar
|
|
||||||
:breadcrumbs="breadcrumbSegments"
|
|
||||||
:is-editing="isEditing"
|
|
||||||
:editing-file-name="ctx.editingFile.value?.name"
|
|
||||||
:editing-file-path="ctx.editingFile.value?.path"
|
|
||||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
|
||||||
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
|
||||||
:search-query="searchQuery"
|
|
||||||
:show-refresh-button="showRefreshButton"
|
|
||||||
:show-install-from-url="ctx.showInstallFromUrl"
|
|
||||||
:base-id="baseId"
|
|
||||||
:disabled="isBusy"
|
|
||||||
:disabled-tooltip="busyTooltip"
|
|
||||||
@navigate="navigateToSegment"
|
|
||||||
@navigate-home="() => navigateToSegment(-1)"
|
|
||||||
@prefetch-home="handlePrefetchHome"
|
|
||||||
@update:search-query="searchQuery = $event"
|
|
||||||
@create="showCreateModal"
|
|
||||||
@upload="initiateFileUpload"
|
|
||||||
@upload-zip="() => {}"
|
|
||||||
@unzip-from-url="showUnzipFromUrlModal"
|
|
||||||
@refresh="ctx.refresh"
|
|
||||||
@share="() => fileEditorRef?.shareToMclogs()"
|
|
||||||
@find="() => fileEditorRef?.toggleFind()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="!isEditing">
|
|
||||||
<FileUploadDragAndDrop
|
|
||||||
ref="fileUploadRef"
|
|
||||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
|
||||||
@files-dropped="handleDroppedFiles"
|
|
||||||
>
|
|
||||||
<FileTableHeader
|
|
||||||
:sort-field="sortField"
|
|
||||||
:sort-desc="sortDescValue"
|
|
||||||
:all-selected="allSelected"
|
|
||||||
:some-selected="someSelected"
|
|
||||||
:is-stuck="isLabelBarStuck"
|
|
||||||
@sort="handleSort"
|
|
||||||
@toggle-all="toggleSelectAll"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="filteredItems.length > 0"
|
|
||||||
ref="virtualListContainer"
|
|
||||||
class="relative w-full"
|
|
||||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
|
||||||
>
|
|
||||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
|
||||||
<FileTableRow
|
|
||||||
v-for="(item, idx) in visibleItems"
|
|
||||||
:key="item.path"
|
|
||||||
:count="item.count"
|
|
||||||
:created="item.created"
|
|
||||||
:modified="item.modified"
|
|
||||||
:name="item.name"
|
|
||||||
:path="item.path"
|
|
||||||
:type="item.type"
|
|
||||||
:size="item.size"
|
|
||||||
:index="visibleRange.start + idx"
|
|
||||||
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
|
||||||
:selected="selectedItems.has(item.path)"
|
|
||||||
:write-disabled="isBusy"
|
|
||||||
:write-disabled-tooltip="busyTooltip"
|
|
||||||
@extract="() => handleExtractItem(item)"
|
|
||||||
@delete="() => showDeleteModal(item)"
|
|
||||||
@rename="() => showRenameModal(item)"
|
|
||||||
@download="() => handleDownload(item)"
|
|
||||||
@move="() => showMoveModal(item)"
|
|
||||||
@move-direct-to="handleDirectMove"
|
|
||||||
@edit="() => handleEditFile(item)"
|
|
||||||
@navigate="() => handleNavigateToFolder(item)"
|
|
||||||
@hover="() => handleItemHover(item)"
|
|
||||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
|
||||||
@toggle-select="() => toggleItemSelection(item.path)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="items.length === 0 && !ctx.error.value"
|
|
||||||
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center gap-4 text-center">
|
|
||||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
|
||||||
<h3 class="m-0 text-2xl font-bold text-contrast">
|
|
||||||
{{ formatMessage(messages.emptyFolderTitle) }}
|
|
||||||
</h3>
|
|
||||||
<p class="m-0 text-sm text-secondary">
|
|
||||||
{{ formatMessage(messages.emptyFolderDescription) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FileManagerError
|
|
||||||
v-else-if="ctx.error.value"
|
|
||||||
class="rounded-b-[20px]"
|
|
||||||
:title="formatMessage(messages.errorTitle)"
|
|
||||||
:message="formatMessage(messages.errorMessage)"
|
|
||||||
@refetch="ctx.refresh"
|
|
||||||
@home="navigateToSegment(-1)"
|
|
||||||
/>
|
|
||||||
</FileUploadDragAndDrop>
|
|
||||||
</div>
|
|
||||||
<FileEditor
|
|
||||||
v-else
|
|
||||||
ref="fileEditorRef"
|
|
||||||
:file="ctx.editingFile.value"
|
|
||||||
:editor-component="editorComponent"
|
|
||||||
@close="handleEditorClose"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FloatingActionBar :shown="hasUnsavedChanges">
|
|
||||||
<p class="m-0 text-sm font-semibold md:text-base">
|
|
||||||
{{ formatMessage(messages.unsavedChanges) }}
|
|
||||||
</p>
|
|
||||||
<div class="ml-auto flex gap-2">
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button @click="fileEditorRef?.revertChanges()">
|
|
||||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
|
||||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</FloatingActionBar>
|
|
||||||
<FloatingActionBar :shown="selectedItems.size > 0">
|
|
||||||
<div class="flex items-center gap-0.5">
|
|
||||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
|
||||||
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
|
||||||
</span>
|
|
||||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button class="!text-primary" @click="deselectAll">
|
|
||||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<div class="ml-auto flex items-center gap-0.5">
|
|
||||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
||||||
<ButtonStyled
|
|
||||||
type="transparent"
|
|
||||||
color="red"
|
|
||||||
color-fill="text"
|
|
||||||
hover-color-fill="background"
|
|
||||||
>
|
>
|
||||||
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
<FileTableHeader
|
||||||
<TrashIcon />
|
:sort-field="sortField"
|
||||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
:sort-desc="sortDescValue"
|
||||||
</button>
|
:all-selected="allSelected"
|
||||||
</ButtonStyled>
|
:some-selected="someSelected"
|
||||||
|
:is-stuck="isLabelBarStuck"
|
||||||
|
@sort="handleSort"
|
||||||
|
@toggle-all="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="filteredItems.length > 0"
|
||||||
|
ref="virtualListContainer"
|
||||||
|
class="relative w-full"
|
||||||
|
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||||
|
>
|
||||||
|
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||||
|
<FileTableRow
|
||||||
|
v-for="(item, idx) in visibleItems"
|
||||||
|
:key="item.path"
|
||||||
|
:count="item.count"
|
||||||
|
:created="item.created"
|
||||||
|
:modified="item.modified"
|
||||||
|
:name="item.name"
|
||||||
|
:path="item.path"
|
||||||
|
:type="item.type"
|
||||||
|
:size="item.size"
|
||||||
|
:index="visibleRange.start + idx"
|
||||||
|
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
||||||
|
:selected="selectedItems.has(item.path)"
|
||||||
|
:write-disabled="isBusy"
|
||||||
|
:write-disabled-tooltip="busyTooltip"
|
||||||
|
@extract="() => handleExtractItem(item)"
|
||||||
|
@delete="() => showDeleteModal(item)"
|
||||||
|
@rename="() => showRenameModal(item)"
|
||||||
|
@download="() => handleDownload(item)"
|
||||||
|
@move="() => showMoveModal(item)"
|
||||||
|
@move-direct-to="handleDirectMove"
|
||||||
|
@edit="() => handleEditFile(item)"
|
||||||
|
@navigate="() => handleNavigateToFolder(item)"
|
||||||
|
@hover="() => handleItemHover(item)"
|
||||||
|
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||||
|
@toggle-select="() => toggleItemSelection(item.path)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="items.length === 0 && !ctx.error.value"
|
||||||
|
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||||
|
<h3 class="m-0 text-2xl font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.emptyFolderTitle) }}
|
||||||
|
</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.emptyFolderDescription) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FileManagerError
|
||||||
|
v-else-if="ctx.error.value"
|
||||||
|
class="rounded-b-[20px]"
|
||||||
|
:title="formatMessage(messages.errorTitle)"
|
||||||
|
:message="formatMessage(messages.errorMessage)"
|
||||||
|
@refetch="ctx.refresh"
|
||||||
|
@home="navigateToSegment(-1)"
|
||||||
|
/>
|
||||||
|
</FileUploadDragAndDrop>
|
||||||
</div>
|
</div>
|
||||||
</FloatingActionBar>
|
<FileEditor
|
||||||
|
v-else
|
||||||
|
ref="fileEditorRef"
|
||||||
|
:file="ctx.editingFile.value"
|
||||||
|
:editor-component="editorComponent"
|
||||||
|
@close="handleEditorClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
<FloatingActionBar :shown="hasUnsavedChanges">
|
||||||
|
<p class="m-0 text-sm font-semibold md:text-base">
|
||||||
|
{{ formatMessage(messages.unsavedChanges) }}
|
||||||
|
</p>
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button @click="fileEditorRef?.revertChanges()">
|
||||||
|
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||||
|
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</FloatingActionBar>
|
||||||
|
<FloatingActionBar :shown="selectedItems.size > 0">
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||||
|
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
||||||
|
</span>
|
||||||
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button class="!text-primary" @click="deselectAll">
|
||||||
|
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-0.5">
|
||||||
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||||
|
<ButtonStyled
|
||||||
|
type="transparent"
|
||||||
|
color="red"
|
||||||
|
color-fill="text"
|
||||||
|
hover-color-fill="background"
|
||||||
|
>
|
||||||
|
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
||||||
|
<TrashIcon />
|
||||||
|
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</FloatingActionBar>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -216,7 +205,6 @@ import {
|
|||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
SpinnerIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
@@ -256,10 +244,6 @@ import type { FileContextMenuOption, FileItem } from './types'
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
loadingFiles: {
|
|
||||||
id: 'files.layout.loading',
|
|
||||||
defaultMessage: 'Loading files...',
|
|
||||||
},
|
|
||||||
busyWarning: {
|
busyWarning: {
|
||||||
id: 'files.layout.busy-warning',
|
id: 'files.layout.busy-warning',
|
||||||
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
||||||
|
|||||||
@@ -27,122 +27,122 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else key="content" class="contents">
|
<div v-else key="content" class="contents">
|
||||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
<ReadyTransition :pending="backupsReadyPending">
|
||||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||||
<BackupRestoreModal ref="restoreBackupModal" />
|
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||||
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
<BackupRestoreModal ref="restoreBackupModal" />
|
||||||
|
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
||||||
|
|
||||||
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
|
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
|
||||||
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
v-tooltip="backupCreationDisabled"
|
v-tooltip="backupCreationDisabled"
|
||||||
:disabled="!!backupCreationDisabled"
|
:disabled="!!backupCreationDisabled"
|
||||||
@click="showCreateModel"
|
@click="showCreateModel"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-5" />
|
<PlusIcon class="size-5" />
|
||||||
Create backup
|
Create backup
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-1.5">
|
<template v-if="backupsData">
|
||||||
<Transition name="fade" mode="out-in">
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
<div
|
<Transition name="fade" mode="out-in">
|
||||||
v-if="groupedBackups.length === 0"
|
<div
|
||||||
key="empty"
|
v-if="groupedBackups.length === 0"
|
||||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
key="empty"
|
||||||
>
|
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||||
<template v-if="!backupsData">
|
|
||||||
<SpinnerIcon class="animate-spin" />
|
|
||||||
Loading backups...
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<EmptyState
|
|
||||||
type="empty-inbox"
|
|
||||||
heading="No backups yet"
|
|
||||||
description="Create your first backup"
|
|
||||||
>
|
>
|
||||||
<template #actions>
|
<EmptyState
|
||||||
<ButtonStyled color="brand">
|
type="empty-inbox"
|
||||||
<button
|
heading="No backups yet"
|
||||||
v-tooltip="backupCreationDisabled"
|
description="Create your first backup"
|
||||||
:disabled="!!backupCreationDisabled"
|
>
|
||||||
class="w-min mx-auto"
|
<template #actions>
|
||||||
@click="showCreateModel"
|
<ButtonStyled color="brand">
|
||||||
>
|
<button
|
||||||
<PlusIcon class="size-5" />
|
v-tooltip="backupCreationDisabled"
|
||||||
Create backup
|
:disabled="!!backupCreationDisabled"
|
||||||
</button>
|
class="w-min mx-auto"
|
||||||
</ButtonStyled>
|
@click="showCreateModel"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-5" />
|
||||||
|
Create backup
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||||
|
<template v-for="group in groupedBackups" :key="group.label">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
||||||
|
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex w-5 justify-center">
|
||||||
|
<div class="h-full w-px bg-surface-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
||||||
|
<BackupItem
|
||||||
|
v-for="backup in group.backups"
|
||||||
|
:key="`backup-${backup.id}`"
|
||||||
|
:backup="backup"
|
||||||
|
:restore-disabled="backupRestoreDisabled"
|
||||||
|
:kyros-url="server.node?.instance"
|
||||||
|
:jwt="server.node?.token"
|
||||||
|
:show-copy-id-action="showCopyIdAction"
|
||||||
|
:show-debug-info="showDebugInfo"
|
||||||
|
@download="() => triggerDownloadAnimation()"
|
||||||
|
@rename="() => renameBackupModal?.show(backup)"
|
||||||
|
@restore="() => restoreBackupModal?.show(backup)"
|
||||||
|
@delete="
|
||||||
|
(skipConfirmation?: boolean) =>
|
||||||
|
skipConfirmation
|
||||||
|
? deleteBackup(backup)
|
||||||
|
: deleteBackupModal?.show(backup)
|
||||||
|
"
|
||||||
|
@retry="() => retryBackup(backup.id)"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</EmptyState>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
|
||||||
<template v-for="group in groupedBackups" :key="group.label">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
|
||||||
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="flex w-5 justify-center">
|
|
||||||
<div class="h-full w-px bg-surface-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
|
||||||
<BackupItem
|
|
||||||
v-for="backup in group.backups"
|
|
||||||
:key="`backup-${backup.id}`"
|
|
||||||
:backup="backup"
|
|
||||||
:restore-disabled="backupRestoreDisabled"
|
|
||||||
:kyros-url="server.node?.instance"
|
|
||||||
:jwt="server.node?.token"
|
|
||||||
:show-copy-id-action="showCopyIdAction"
|
|
||||||
:show-debug-info="showDebugInfo"
|
|
||||||
@download="() => triggerDownloadAnimation()"
|
|
||||||
@rename="() => renameBackupModal?.show(backup)"
|
|
||||||
@restore="() => restoreBackupModal?.show(backup)"
|
|
||||||
@delete="
|
|
||||||
(skipConfirmation?: boolean) =>
|
|
||||||
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
|
||||||
"
|
|
||||||
@retry="() => retryBackup(backup.id)"
|
|
||||||
/>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="over-the-top-download-animation"
|
class="over-the-top-download-animation"
|
||||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||||
>
|
>
|
||||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ReadyTransition>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon } from '@modrinth/api-client'
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
@@ -151,11 +151,13 @@ import { useRoute } from 'vue-router'
|
|||||||
|
|
||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
import EmptyState from '#ui/components/base/EmptyState.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 BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
|
||||||
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
|
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
|
||||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||||
|
import { useReadyState } from '#ui/composables'
|
||||||
import { useVIntl } from '#ui/composables/i18n'
|
import { useVIntl } from '#ui/composables/i18n'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -184,13 +186,17 @@ defineEmits(['onDownload'])
|
|||||||
const backupsQueryKey = ['backups', 'list', serverId]
|
const backupsQueryKey = ['backups', 'list', serverId]
|
||||||
const {
|
const {
|
||||||
data: backupsData,
|
data: backupsData,
|
||||||
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: backupsQueryKey,
|
queryKey: backupsQueryKey,
|
||||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||||
|
enabled: computed(() => worldId.value !== null),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const backupsReadyPending = useReadyState({ isLoading, data: backupsData })
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (backupId: string) =>
|
mutationFn: (backupId: string) =>
|
||||||
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
|
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 { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||||
|
import { useReadyState } from '#ui/composables'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -121,6 +123,8 @@ const contentQuery = useQuery({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const contentReadyPending = useReadyState(contentQuery)
|
||||||
|
|
||||||
const modpackProjectId = computed(() => {
|
const modpackProjectId = computed(() => {
|
||||||
const spec = contentQuery.data.value?.modpack?.spec
|
const spec = contentQuery.data.value?.modpack?.spec
|
||||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||||
@@ -906,50 +910,52 @@ provideContentManager({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ContentPageLayout>
|
<ReadyTransition :pending="contentReadyPending">
|
||||||
<template #modals>
|
<ContentPageLayout>
|
||||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
<template #modals>
|
||||||
<ModpackContentModal
|
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||||
ref="modpackContentModal"
|
<ModpackContentModal
|
||||||
:modpack-name="modpack?.project.title"
|
ref="modpackContentModal"
|
||||||
:modpack-icon-url="modpack?.project.icon_url"
|
:modpack-name="modpack?.project.title"
|
||||||
enable-toggle
|
:modpack-icon-url="modpack?.project.icon_url"
|
||||||
@update:enabled="handleModpackContentToggle"
|
enable-toggle
|
||||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
@update:enabled="handleModpackContentToggle"
|
||||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||||
/>
|
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||||
<ContentUpdaterModal
|
/>
|
||||||
v-if="updatingProject || updatingModpack"
|
<ContentUpdaterModal
|
||||||
ref="contentUpdaterModal"
|
v-if="updatingProject || updatingModpack"
|
||||||
:versions="updatingProjectVersions"
|
ref="contentUpdaterModal"
|
||||||
:current-game-version="currentGameVersion"
|
:versions="updatingProjectVersions"
|
||||||
:current-loader="currentLoader"
|
:current-game-version="currentGameVersion"
|
||||||
:current-version-id="
|
:current-loader="currentLoader"
|
||||||
updatingModpack
|
:current-version-id="
|
||||||
? contentQuery.data.value?.modpack?.spec.platform === 'modrinth'
|
updatingModpack
|
||||||
? contentQuery.data.value.modpack.spec.version_id
|
? contentQuery.data.value?.modpack?.spec.platform === 'modrinth'
|
||||||
: ''
|
? contentQuery.data.value.modpack.spec.version_id
|
||||||
: (updatingProject?.version?.id ?? '')
|
: ''
|
||||||
"
|
: (updatingProject?.version?.id ?? '')
|
||||||
:is-app="false"
|
"
|
||||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
:is-app="false"
|
||||||
:project-icon-url="
|
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||||
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
|
:project-icon-url="
|
||||||
"
|
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
|
||||||
:project-name="
|
"
|
||||||
updatingModpack
|
:project-name="
|
||||||
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
|
updatingModpack
|
||||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
|
||||||
"
|
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||||
:loading="loadingVersions"
|
"
|
||||||
:loading-changelog="loadingChangelog"
|
:loading="loadingVersions"
|
||||||
@update="handleModalUpdate"
|
:loading-changelog="loadingChangelog"
|
||||||
@cancel="resetUpdateState"
|
@update="handleModalUpdate"
|
||||||
@version-select="handleVersionSelect"
|
@cancel="resetUpdateState"
|
||||||
@version-hover="handleVersionHover"
|
@version-select="handleVersionSelect"
|
||||||
/>
|
@version-hover="handleVersionHover"
|
||||||
</template>
|
/>
|
||||||
</ContentPageLayout>
|
</template>
|
||||||
|
</ContentPageLayout>
|
||||||
|
</ReadyTransition>
|
||||||
<ConfirmModpackUpdateModal
|
<ConfirmModpackUpdateModal
|
||||||
ref="modpackUpdateModal"
|
ref="modpackUpdateModal"
|
||||||
:downgrade="isModpackUpdateDowngrade"
|
:downgrade="isModpackUpdateDowngrade"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { useVIntl } from '#ui/composables/i18n'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -113,6 +115,8 @@ const {
|
|||||||
|
|
||||||
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
||||||
|
|
||||||
|
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
|
||||||
|
|
||||||
// Prefetching
|
// Prefetching
|
||||||
function prefetchDirectory(path: string) {
|
function prefetchDirectory(path: string) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
@@ -473,8 +477,10 @@ provideFileManager({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FilePageLayout
|
<ReadyTransition :pending="filesReadyPending">
|
||||||
:show-debug-info="props.showDebugInfo"
|
<FilePageLayout
|
||||||
:show-refresh-button="props.showRefreshButton"
|
:show-debug-info="props.showDebugInfo"
|
||||||
/>
|
:show-refresh-button="props.showRefreshButton"
|
||||||
|
/>
|
||||||
|
</ReadyTransition>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -80,115 +80,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition v-else name="fade" mode="out-in">
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-if="(isLoading || !authReady) && !serverResponse"
|
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
||||||
key="loading"
|
|
||||||
class="flex flex-col gap-4 py-8"
|
|
||||||
>
|
>
|
||||||
<div class="mb-4 text-center">
|
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
||||||
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
{{ formatMessage(messages.serversTitle) }}
|
||||||
<p class="m-0 mt-2 text-secondary">{{ formatMessage(messages.loadingServers) }}</p>
|
</h1>
|
||||||
</div>
|
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
|
||||||
<div
|
<StyledInput
|
||||||
v-for="i in 3"
|
id="search"
|
||||||
:key="i"
|
v-model="searchInput"
|
||||||
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
|
:icon="SearchIcon"
|
||||||
>
|
type="search"
|
||||||
<div class="size-16 rounded-xl bg-button-bg"></div>
|
name="search"
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
autocomplete="off"
|
||||||
<div class="h-6 w-48 rounded bg-button-bg"></div>
|
:disabled="showServersListLoading"
|
||||||
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
|
:placeholder="formatMessage(messages.searchPlaceholder, { count: filteredData.length })"
|
||||||
</div>
|
wrapper-class="w-full md:w-72"
|
||||||
|
/>
|
||||||
|
<ButtonStyled type="standard" color="brand">
|
||||||
|
<button @click="openPurchaseModal">
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.newServerButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Transition name="fade" mode="out-in">
|
||||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
<div v-if="showServersListLoading" key="loading" class="flex flex-col gap-3">
|
||||||
key="empty"
|
|
||||||
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
|
||||||
>
|
|
||||||
<ServerListEmpty
|
|
||||||
:logged-in="loggedIn"
|
|
||||||
@click-new-server="openPurchaseModal"
|
|
||||||
@click-sign-in="handleSignIn"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else key="list">
|
|
||||||
<div
|
|
||||||
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
|
||||||
>
|
|
||||||
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
|
||||||
{{ formatMessage(messages.serversTitle) }}
|
|
||||||
</h1>
|
|
||||||
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
|
|
||||||
<StyledInput
|
|
||||||
id="search"
|
|
||||||
v-model="searchInput"
|
|
||||||
:icon="SearchIcon"
|
|
||||||
type="search"
|
|
||||||
name="search"
|
|
||||||
autocomplete="off"
|
|
||||||
:placeholder="
|
|
||||||
formatMessage(messages.searchPlaceholder, { count: filteredData.length })
|
|
||||||
"
|
|
||||||
wrapper-class="w-full md:w-72"
|
|
||||||
/>
|
|
||||||
<ButtonStyled type="standard" color="brand">
|
|
||||||
<button @click="openPurchaseModal">
|
|
||||||
<PlusIcon />
|
|
||||||
{{ formatMessage(messages.newServerButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 max-h-0"
|
|
||||||
enter-to-class="opacity-100 max-h-20"
|
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
|
||||||
leave-from-class="opacity-100 max-h-20"
|
|
||||||
leave-to-class="opacity-0 max-h-0"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="showPollingForNewServers"
|
v-for="i in 3"
|
||||||
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
:key="i"
|
||||||
|
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
|
||||||
>
|
>
|
||||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
<div class="size-16 rounded-xl bg-button-bg"></div>
|
||||||
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
|
<div class="h-6 w-48 rounded bg-button-bg"></div>
|
||||||
|
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<TransitionGroup
|
|
||||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
|
||||||
name="list"
|
|
||||||
tag="ul"
|
|
||||||
class="m-0 flex flex-col gap-3 p-0"
|
|
||||||
>
|
|
||||||
<MedalServerListing
|
|
||||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
|
||||||
:key="server.server_id"
|
|
||||||
v-bind="server"
|
|
||||||
@upgrade="openPurchaseModal"
|
|
||||||
/>
|
|
||||||
<ServerListing
|
|
||||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
|
||||||
:key="server.server_id"
|
|
||||||
v-bind="server"
|
|
||||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
|
||||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
|
||||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
|
||||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
|
||||||
/>
|
|
||||||
</TransitionGroup>
|
|
||||||
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
|
|
||||||
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
|
||||||
</div>
|
<div
|
||||||
</Transition>
|
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||||
|
key="empty"
|
||||||
|
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
||||||
|
>
|
||||||
|
<ServerListEmpty
|
||||||
|
:logged-in="loggedIn"
|
||||||
|
@click-new-server="openPurchaseModal"
|
||||||
|
@click-sign-in="handleSignIn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else key="list">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-20"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 max-h-20"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showPollingForNewServers"
|
||||||
|
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
||||||
|
>
|
||||||
|
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||||
|
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<TransitionGroup
|
||||||
|
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||||
|
name="list"
|
||||||
|
tag="ul"
|
||||||
|
class="m-0 flex flex-col gap-3 p-0"
|
||||||
|
>
|
||||||
|
<MedalServerListing
|
||||||
|
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||||
|
:key="server.server_id"
|
||||||
|
v-bind="server"
|
||||||
|
@upgrade="openPurchaseModal"
|
||||||
|
/>
|
||||||
|
<ServerListing
|
||||||
|
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||||
|
:key="server.server_id"
|
||||||
|
v-bind="server"
|
||||||
|
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||||
|
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||||
|
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||||
|
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -236,7 +226,6 @@ const route = useRoute()
|
|||||||
const auth = injectAuth()
|
const auth = injectAuth()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const loggedIn = computed(() => !!auth.user.value)
|
const loggedIn = computed(() => !!auth.user.value)
|
||||||
const authReady = computed(() => auth.isReady?.value ?? true)
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -266,10 +255,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Contact Modrinth Support',
|
defaultMessage: 'Contact Modrinth Support',
|
||||||
},
|
},
|
||||||
reloadButton: { id: 'servers.manage.reload-button', defaultMessage: 'Reload' },
|
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' },
|
serversTitle: { id: 'servers.manage.servers-title', defaultMessage: 'Modrinth Hosting' },
|
||||||
searchPlaceholder: {
|
searchPlaceholder: {
|
||||||
id: 'servers.manage.search-placeholder',
|
id: 'servers.manage.search-placeholder',
|
||||||
@@ -509,7 +494,7 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
|||||||
const {
|
const {
|
||||||
data: serverResponse,
|
data: serverResponse,
|
||||||
error: fetchError,
|
error: fetchError,
|
||||||
isLoading,
|
isPending: serversQueryPending,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['servers'],
|
queryKey: ['servers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -556,6 +541,9 @@ const {
|
|||||||
|
|
||||||
const hasError = computed(() => loggedIn.value && !!fetchError.value)
|
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[]>(() => {
|
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||||
if (!loggedIn.value || !serverResponse.value) return []
|
if (!loggedIn.value || !serverResponse.value) return []
|
||||||
return serverResponse.value.servers
|
return serverResponse.value.servers
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { Mclogs } from '@modrinth/api-client'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|||||||
@@ -95,14 +95,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</ErrorInformationCard>
|
</ErrorInformationCard>
|
||||||
</div>
|
</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 -->
|
<!-- SERVER START -->
|
||||||
<div
|
<div
|
||||||
v-else-if="serverData"
|
v-else-if="serverData"
|
||||||
@@ -120,14 +112,7 @@
|
|||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<template v-if="revealState !== 'pending' || isOnboarding">
|
||||||
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>
|
|
||||||
<ServerManageHeader
|
<ServerManageHeader
|
||||||
v-if="!isOnboarding"
|
v-if="!isOnboarding"
|
||||||
class="server-stagger-item"
|
class="server-stagger-item"
|
||||||
@@ -463,7 +448,9 @@ import {
|
|||||||
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
||||||
import {
|
import {
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
|
useLoadingBarToken,
|
||||||
useModrinthServersConsole,
|
useModrinthServersConsole,
|
||||||
|
useReadyState,
|
||||||
useServerImage,
|
useServerImage,
|
||||||
useServerProject,
|
useServerProject,
|
||||||
} from '#ui/composables'
|
} from '#ui/composables'
|
||||||
@@ -536,13 +523,6 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const loadingMessages = defineMessages({
|
|
||||||
loadingServerPanel: {
|
|
||||||
id: 'servers.manage.loading.serverPanel',
|
|
||||||
defaultMessage: 'Loading your server panel...',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const leaveMessages = defineMessages({
|
const leaveMessages = defineMessages({
|
||||||
uploadInProgress: {
|
uploadInProgress: {
|
||||||
id: 'servers.manage.confirm-leave.upload-in-progress',
|
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 { addNotification } = injectNotificationManager()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||||
@@ -599,11 +582,17 @@ function dismissSettingsHint() {
|
|||||||
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
|
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
|
||||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
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],
|
queryKey: ['servers', 'detail', props.serverId],
|
||||||
queryFn: () => client.archon.servers_v0.get(props.serverId)!,
|
queryFn: () => client.archon.servers_v0.get(props.serverId)!,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useLoadingBarToken(useReadyState({ isLoading: serverLoading, data: serverData }))
|
||||||
|
|
||||||
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
|
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
|
||||||
if (!serverData.value) return
|
if (!serverData.value) return
|
||||||
queryClient.setQueryData(['servers', 'detail', props.serverId], {
|
queryClient.setQueryData(['servers', 'detail', props.serverId], {
|
||||||
@@ -817,7 +806,7 @@ log('canReveal initial', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const revealState = ref<'pending' | 'revealing' | 'visible'>(
|
const revealState = ref<'pending' | 'revealing' | 'visible'>(
|
||||||
canReveal.value ? 'visible' : 'pending',
|
DISABLE_LOADING_ANIM || canReveal.value ? 'visible' : 'pending',
|
||||||
)
|
)
|
||||||
log('revealState initial', revealState.value)
|
log('revealState initial', revealState.value)
|
||||||
|
|
||||||
@@ -826,11 +815,15 @@ const REVEAL_TOTAL_MS = 2 * 80 + 400
|
|||||||
watch(canReveal, (ready) => {
|
watch(canReveal, (ready) => {
|
||||||
log('canReveal changed', { ready, revealState: revealState.value })
|
log('canReveal changed', { ready, revealState: revealState.value })
|
||||||
if (ready && revealState.value === 'pending') {
|
if (ready && revealState.value === 'pending') {
|
||||||
revealState.value = 'revealing'
|
if (DISABLE_LOADING_ANIM) {
|
||||||
setTimeout(() => {
|
|
||||||
revealState.value = 'visible'
|
revealState.value = 'visible'
|
||||||
log('revealState -> visible')
|
} else {
|
||||||
}, REVEAL_TOTAL_MS)
|
revealState.value = 'revealing'
|
||||||
|
setTimeout(() => {
|
||||||
|
revealState.value = 'visible'
|
||||||
|
log('revealState -> visible')
|
||||||
|
}, REVEAL_TOTAL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -644,9 +644,6 @@
|
|||||||
"files.layout.extraction-started-title": {
|
"files.layout.extraction-started-title": {
|
||||||
"defaultMessage": "Extraction started"
|
"defaultMessage": "Extraction started"
|
||||||
},
|
},
|
||||||
"files.layout.loading": {
|
|
||||||
"defaultMessage": "Loading files..."
|
|
||||||
},
|
|
||||||
"files.layout.selected-count": {
|
"files.layout.selected-count": {
|
||||||
"defaultMessage": "{count} selected"
|
"defaultMessage": "{count} selected"
|
||||||
},
|
},
|
||||||
@@ -2876,12 +2873,6 @@
|
|||||||
"servers.manage.handle-error.title": {
|
"servers.manage.handle-error.title": {
|
||||||
"defaultMessage": "An error occurred"
|
"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": {
|
"servers.manage.new-server-button": {
|
||||||
"defaultMessage": "New server"
|
"defaultMessage": "New server"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from './file-picker'
|
|||||||
export * from './hosting-purchase-intent'
|
export * from './hosting-purchase-intent'
|
||||||
export * from './i18n'
|
export * from './i18n'
|
||||||
export * from './instance-import'
|
export * from './instance-import'
|
||||||
|
export * from './loading-state'
|
||||||
export * from './modal-behavior'
|
export * from './modal-behavior'
|
||||||
export * from './page-context'
|
export * from './page-context'
|
||||||
export * from './popup-notifications'
|
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>
|
</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.
|
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
|
## Composables
|
||||||
|
|||||||
Reference in New Issue
Block a user