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:
148
packages/ui/src/components/base/LoadingBar.vue
Normal file
148
packages/ui/src/components/base/LoadingBar.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Bar height in pixels. */
|
||||
height?: number
|
||||
/** Background gradient. Defaults to the brand green. */
|
||||
color?: string
|
||||
/** Total bar fill duration in ms (visual progress easing). */
|
||||
duration?: number
|
||||
/** Delay in ms before the bar becomes visible after a load begins. */
|
||||
throttle?: number
|
||||
/** CSS position. Use `absolute` when wrapping in a custom positioned container (e.g. desktop top-bar offset). */
|
||||
position?: 'fixed' | 'absolute'
|
||||
/** Top offset CSS value. */
|
||||
offsetTop?: string
|
||||
/** Left offset CSS value. */
|
||||
offsetLeft?: string
|
||||
/** Right offset CSS value. */
|
||||
offsetRight?: string
|
||||
}>(),
|
||||
{
|
||||
height: 2,
|
||||
color: 'var(--loading-bar-gradient)',
|
||||
duration: 1000,
|
||||
throttle: 0,
|
||||
position: 'fixed',
|
||||
offsetTop: '0',
|
||||
offsetLeft: '0',
|
||||
offsetRight: '0',
|
||||
},
|
||||
)
|
||||
|
||||
const loadingState = injectLoadingState(null)
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const step = computed(() => 10000 / props.duration)
|
||||
|
||||
let _timer: ReturnType<typeof setInterval> | null = null
|
||||
let _throttle: ReturnType<typeof setTimeout> | null = null
|
||||
let _hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let _resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (_timer) clearInterval(_timer)
|
||||
if (_throttle) clearTimeout(_throttle)
|
||||
if (_hideTimeout) clearTimeout(_hideTimeout)
|
||||
if (_resetTimeout) clearTimeout(_resetTimeout)
|
||||
_timer = null
|
||||
_throttle = null
|
||||
_hideTimeout = null
|
||||
_resetTimeout = null
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (typeof window === 'undefined') return
|
||||
_timer = setInterval(() => {
|
||||
progress.value = Math.min(100, progress.value + step.value)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
progress.value = 0
|
||||
if (props.throttle && typeof window !== 'undefined') {
|
||||
_throttle = setTimeout(() => {
|
||||
isVisible.value = true
|
||||
startTimer()
|
||||
}, props.throttle)
|
||||
} else {
|
||||
isVisible.value = true
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
progress.value = 100
|
||||
clearTimers()
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
return
|
||||
}
|
||||
_hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
_resetTimeout = setTimeout(() => {
|
||||
progress.value = 0
|
||||
}, 400)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loadingState) {
|
||||
watch(
|
||||
() => loadingState.pending.value && loadingState.barEnabled.value,
|
||||
(active) => {
|
||||
if (active) start()
|
||||
else finish()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="modrinth-loading-bar"
|
||||
:style="{
|
||||
position: props.position,
|
||||
top: props.offsetTop,
|
||||
right: props.offsetRight,
|
||||
left: props.offsetLeft,
|
||||
pointerEvents: 'none',
|
||||
width: `${progress}%`,
|
||||
height: `${isVisible ? props.height : 0}px`,
|
||||
borderRadius: `${props.height}px`,
|
||||
background: props.color,
|
||||
backgroundSize: `${(100 / Math.max(progress, 0.01)) * 100}% auto`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'width 0.1s ease-in-out, height 0.1s ease-out, opacity 0.4s',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-loading-bar {
|
||||
z-index: 999999;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
packages/ui/src/components/base/ReadyTransition.vue
Normal file
98
packages/ui/src/components/base/ReadyTransition.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* If `pending` is false on mount and never becomes true, the slot renders with no
|
||||
* enter transition (cache-hit fast path). After a real pending phase, transitions
|
||||
* behave as before for subsequent toggles.
|
||||
*/
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, toRef, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** True while the wrapped content is still loading. Slot stays blank, loading bar runs. */
|
||||
pending: boolean | Ref<boolean>
|
||||
/** Fade duration applied to the slot when content reveals. */
|
||||
duration?: number
|
||||
/** When true, do NOT register a token with the global loading bar — only fade locally. */
|
||||
silent?: boolean
|
||||
}>(),
|
||||
{
|
||||
duration: 200,
|
||||
silent: false,
|
||||
},
|
||||
)
|
||||
|
||||
const pendingRef = toRef(props, 'pending') as Ref<boolean | Ref<boolean>>
|
||||
const resolvedPending = computed(() => {
|
||||
const v = pendingRef.value
|
||||
if (typeof v === 'boolean') return v
|
||||
return Boolean((v as Ref<boolean>).value)
|
||||
})
|
||||
|
||||
const hasBeenPending = ref(false)
|
||||
const useShell = computed(() => resolvedPending.value || hasBeenPending.value)
|
||||
|
||||
const loadingState = injectLoadingState(null)
|
||||
let token: symbol | null = null
|
||||
|
||||
function release() {
|
||||
if (token && loadingState) {
|
||||
loadingState.end(token)
|
||||
}
|
||||
token = null
|
||||
}
|
||||
|
||||
watch(
|
||||
resolvedPending,
|
||||
(now) => {
|
||||
if (now) {
|
||||
hasBeenPending.value = true
|
||||
}
|
||||
if (loadingState && !props.silent && typeof window !== 'undefined') {
|
||||
if (now) {
|
||||
if (!token) token = loadingState.begin()
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(release)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="useShell">
|
||||
<Transition name="ready-fade" mode="out-in" :duration="props.duration">
|
||||
<div v-if="!resolvedPending" key="content" class="ready-transition-content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else key="pending" aria-hidden="true" class="ready-transition-pending" />
|
||||
</Transition>
|
||||
</template>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ready-fade-enter-active,
|
||||
.ready-fade-leave-active {
|
||||
transition: opacity v-bind('`${props.duration}ms`') ease-in-out;
|
||||
}
|
||||
|
||||
.ready-fade-enter-from,
|
||||
.ready-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ready-transition-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ready-transition-pending {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,7 @@ export { default as IconSelect } from './IconSelect.vue'
|
||||
export { default as IntlFormatted } from './IntlFormatted.vue'
|
||||
export type { JoinedButtonAction } from './JoinedButtons.vue'
|
||||
export { default as JoinedButtons } from './JoinedButtons.vue'
|
||||
export { default as LoadingBar } from './LoadingBar.vue'
|
||||
export { default as LoadingIndicator } from './LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './MarkdownEditor.vue'
|
||||
@@ -62,6 +63,7 @@ export { default as ProgressBar } from './ProgressBar.vue'
|
||||
export { default as ProgressSpinner } from './ProgressSpinner.vue'
|
||||
export { default as RadialHeader } from './RadialHeader.vue'
|
||||
export { default as RadioButtons } from './RadioButtons.vue'
|
||||
export { default as ReadyTransition } from './ReadyTransition.vue'
|
||||
export { default as ScrollablePanel } from './ScrollablePanel.vue'
|
||||
export { default as ServerNotice } from './ServerNotice.vue'
|
||||
export { default as SettingsLabel } from './SettingsLabel.vue'
|
||||
|
||||
@@ -13,6 +13,9 @@ export * from './server-console'
|
||||
export * from './server-manage-core-runtime'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './use-loading-bar-token'
|
||||
export * from './use-loading-state-core'
|
||||
export * from './use-ready-state'
|
||||
export * from './use-server-image'
|
||||
export * from './use-server-project'
|
||||
export * from './virtual-scroll'
|
||||
|
||||
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal file
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
/**
|
||||
* Register a `LoadingBar` token for as long as `pending` is truthy.
|
||||
*
|
||||
* Use this when the component that owns the load is not the natural place
|
||||
* to mount a `<ReadyTransition>` (e.g. a page root with a complex v-if
|
||||
* cascade where wrapping the template is awkward). `<ReadyTransition>`
|
||||
* remains the preferred API when it fits.
|
||||
*
|
||||
* Safe to call without a provider mounted; becomes a no-op.
|
||||
*/
|
||||
export function useLoadingBarToken(pending: Ref<boolean>): void {
|
||||
const loadingState = injectLoadingState(null)
|
||||
if (!loadingState) return
|
||||
|
||||
let token: symbol | null = null
|
||||
|
||||
function release() {
|
||||
if (token) {
|
||||
loadingState.end(token)
|
||||
token = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
pending,
|
||||
(now) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (now && !token) {
|
||||
token = loadingState.begin()
|
||||
} else if (!now) {
|
||||
release()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(release)
|
||||
}
|
||||
61
packages/ui/src/composables/use-loading-state-core.ts
Normal file
61
packages/ui/src/composables/use-loading-state-core.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LoadingStateProvider } from '#ui/providers/loading-state'
|
||||
|
||||
export interface LoadingStateCoreOptions {
|
||||
/** Initial value of the host kill-switch. Default: true. */
|
||||
barEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a token-based `LoadingStateProvider` implementation.
|
||||
*
|
||||
* Multiple `ReadyTransition` instances (or any caller) can hold tokens at the
|
||||
* same time; the bar stays visible while at least one is live. `end(token)`
|
||||
* is idempotent so a stale token release after unmount is harmless.
|
||||
*
|
||||
* SSR safe: timers and DOM access are deferred to component code; this core
|
||||
* is pure reactive state.
|
||||
*/
|
||||
export function createLoadingStateCore(opts: LoadingStateCoreOptions = {}): LoadingStateProvider {
|
||||
const tokens = shallowRef<Set<symbol>>(new Set())
|
||||
const barEnabled = ref(opts.barEnabled ?? true)
|
||||
const pending = computed(() => tokens.value.size > 0)
|
||||
|
||||
function begin(): symbol {
|
||||
const token = Symbol('loading-state-token')
|
||||
const next = new Set(tokens.value)
|
||||
next.add(token)
|
||||
tokens.value = next
|
||||
return token
|
||||
}
|
||||
|
||||
function end(token: symbol): void {
|
||||
if (!tokens.value.has(token)) return
|
||||
const next = new Set(tokens.value)
|
||||
next.delete(token)
|
||||
tokens.value = next
|
||||
}
|
||||
|
||||
function beginManual(durationMs = 500): void {
|
||||
const token = begin()
|
||||
if (typeof window === 'undefined') {
|
||||
end(token)
|
||||
return
|
||||
}
|
||||
window.setTimeout(() => end(token), durationMs)
|
||||
}
|
||||
|
||||
function setEnabled(enabled: boolean): void {
|
||||
barEnabled.value = enabled
|
||||
}
|
||||
|
||||
return {
|
||||
pending,
|
||||
barEnabled,
|
||||
begin,
|
||||
end,
|
||||
beginManual,
|
||||
setEnabled,
|
||||
}
|
||||
}
|
||||
24
packages/ui/src/composables/use-ready-state.ts
Normal file
24
packages/ui/src/composables/use-ready-state.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { DefaultError, UseQueryReturnType } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/** Subset of {@link UseQueryReturnType} passed to {@link useReadyState}. */
|
||||
export type ReadyStateQuery<TData, TError = DefaultError> = Pick<
|
||||
UseQueryReturnType<TData, TError>,
|
||||
'isLoading' | 'data'
|
||||
>
|
||||
|
||||
/**
|
||||
* Returns true while a query is loading for the FIRST time (no cached data yet).
|
||||
*
|
||||
* Excludes background refetches and refetch-on-window-focus by design — those
|
||||
* have `isLoading === false` once data exists in the cache, so `ReadyTransition`
|
||||
* stays open and the loading bar stays silent.
|
||||
*
|
||||
* Pair with `<ReadyTransition :pending="var which is useReadyState(query)" />`.
|
||||
*/
|
||||
export function useReadyState<TData, TError = DefaultError>(
|
||||
query: ReadyStateQuery<TData, TError>,
|
||||
): Readonly<Ref<boolean>> {
|
||||
return computed(() => query.isLoading.value && query.data.value === undefined)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
SpinnerIcon,
|
||||
TextCursorInputIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
@@ -504,304 +503,300 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<div
|
||||
v-if="ctx.loading.value"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex min-h-[50vh] w-full flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loadingContent) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="ctx.error.value"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="universal-card flex flex-col items-center gap-4 p-6">
|
||||
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
|
||||
<p class="text-secondary">{{ ctx.error.value.message }}</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
|
||||
<template #header>{{ ctx.busyMessage.value }}</template>
|
||||
{{ formatMessage(messages.busyDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<ContentModpackCard
|
||||
v-if="ctx.modpack.value"
|
||||
:project="ctx.modpack.value.project"
|
||||
:project-link="ctx.modpack.value.projectLink"
|
||||
:version="ctx.modpack.value.version"
|
||||
:version-link="ctx.modpack.value.versionLink"
|
||||
:owner="ctx.modpack.value.owner"
|
||||
:categories="ctx.modpack.value.categories"
|
||||
:has-update="ctx.modpack.value.hasUpdate"
|
||||
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
||||
:disabled-text="
|
||||
ctx.modpack.value.disabledText ??
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
||||
"
|
||||
:show-content-hint="
|
||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||
"
|
||||
v-on="{
|
||||
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
||||
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
||||
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
||||
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
||||
}"
|
||||
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
||||
/>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
aria-live="polite"
|
||||
<template v-if="!ctx.loading.value">
|
||||
<div
|
||||
v-if="ctx.error.value"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="universal-card flex flex-col items-center gap-4 p-6">
|
||||
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
|
||||
<p class="text-secondary">{{ ctx.error.value.message }}</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
|
||||
<template #header>{{ ctx.busyMessage.value }}</template>
|
||||
{{ formatMessage(messages.busyDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<ContentModpackCard
|
||||
v-if="ctx.modpack.value"
|
||||
:project="ctx.modpack.value.project"
|
||||
:project-link="ctx.modpack.value.projectLink"
|
||||
:version="ctx.modpack.value.version"
|
||||
:version-link="ctx.modpack.value.versionLink"
|
||||
:owner="ctx.modpack.value.owner"
|
||||
:categories="ctx.modpack.value.categories"
|
||||
:has-update="ctx.modpack.value.hasUpdate"
|
||||
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
||||
:disabled-text="
|
||||
ctx.modpack.value.disabledText ??
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
||||
"
|
||||
:show-content-hint="
|
||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||
"
|
||||
v-on="{
|
||||
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
||||
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
||||
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
||||
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
||||
}"
|
||||
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
||||
/>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Admonition
|
||||
v-if="ctx.uploadState?.value?.isUploading"
|
||||
type="info"
|
||||
show-actions-underneath
|
||||
>
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
formatMessage(messages.uploadingFiles, {
|
||||
completed: ctx.uploadState?.value?.completedFiles ?? 0,
|
||||
total: ctx.uploadState?.value?.totalFiles ?? 0,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
|
||||
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
|
||||
Math.round(uploadOverallProgress * 100)
|
||||
}}%)
|
||||
</span>
|
||||
<template #actions>
|
||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
</Transition>
|
||||
|
||||
<template v-if="ctx.items.value.length > 0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.additionalContent) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: tableItems.length,
|
||||
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 !border-button-bg !border-[1px]"
|
||||
@click="ctx.uploadFiles"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(messages.uploadFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="@container flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selectedFilters.length === 0
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
:aria-pressed="selectedFilters.length === 0"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selectedFilters.includes(option.id)
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
:aria-pressed="selectedFilters.includes(option.id)"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<div class="hidden @[900px]:block">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
/><ArrowDownAZIcon v-else />
|
||||
{{ sortLabels[sortMode]() }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="@[900px]:hidden">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
/><ArrowDownAZIcon v-else />
|
||||
{{ sortLabels[sortMode]() }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
|
||||
color="green"
|
||||
type="transparent"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
||||
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentCardTable
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="true"
|
||||
@update:enabled="handleToggleEnabledById"
|
||||
@delete="handleDeleteById"
|
||||
@update="handleUpdateById"
|
||||
@switch-version="handleSwitchVersionById"
|
||||
>
|
||||
<template #empty>
|
||||
<span>{{ formatMessage(messages.noContentFound) }}</span>
|
||||
</template>
|
||||
</ContentCardTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<EmptyState v-else type="empty-inbox">
|
||||
<template #heading>
|
||||
{{
|
||||
formatMessage(messages.uploadingFiles, {
|
||||
completed: ctx.uploadState?.value?.completedFiles ?? 0,
|
||||
total: ctx.uploadState?.value?.totalFiles ?? 0,
|
||||
})
|
||||
formatMessage(
|
||||
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
|
||||
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
|
||||
Math.round(uploadOverallProgress * 100)
|
||||
}}%)
|
||||
</span>
|
||||
<template #actions>
|
||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||
<template #description>
|
||||
{{
|
||||
ctx.modpack.value
|
||||
? formatMessage(messages.emptyModpackHint)
|
||||
: formatMessage(messages.emptyHint, {
|
||||
contentType: `${ctx.contentTypeLabel.value}s`,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</Admonition>
|
||||
</Transition>
|
||||
|
||||
<template v-if="ctx.items.value.length > 0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.additionalContent) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: tableItems.length,
|
||||
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 !border-button-bg !border-[1px]"
|
||||
@click="ctx.uploadFiles"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(messages.uploadFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="@container flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selectedFilters.length === 0
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:aria-pressed="selectedFilters.length === 0"
|
||||
@click="selectedFilters = []"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 !border-button-bg !border-[1px]"
|
||||
@click="ctx.uploadFiles"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(messages.uploadFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selectedFilters.includes(option.id)
|
||||
? 'border-green bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:aria-pressed="selectedFilters.includes(option.id)"
|
||||
@click="toggleFilter(option.id)"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
{{ option.label }}
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
<div class="hidden @[900px]:block">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
/><ArrowDownAZIcon v-else />
|
||||
{{ sortLabels[sortMode]() }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="@[900px]:hidden">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
/><ArrowDownAZIcon v-else />
|
||||
{{ sortLabels[sortMode]() }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
|
||||
color="green"
|
||||
type="transparent"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
||||
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentCardTable
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="true"
|
||||
@update:enabled="handleToggleEnabledById"
|
||||
@delete="handleDeleteById"
|
||||
@update="handleUpdateById"
|
||||
@switch-version="handleSwitchVersionById"
|
||||
>
|
||||
<template #empty>
|
||||
<span>{{ formatMessage(messages.noContentFound) }}</span>
|
||||
</template>
|
||||
</ContentCardTable>
|
||||
</div>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
|
||||
<EmptyState v-else type="empty-inbox">
|
||||
<template #heading>
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
ctx.modpack.value
|
||||
? formatMessage(messages.emptyModpackHint)
|
||||
: formatMessage(messages.emptyHint, {
|
||||
contentType: `${ctx.contentTypeLabel.value}s`,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 !border-button-bg !border-[1px]"
|
||||
@click="ctx.uploadFiles"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(messages.uploadFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
|
||||
<ContentSelectionBar
|
||||
|
||||
@@ -31,180 +31,169 @@
|
||||
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</FileContextMenu>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="ctx.loading.value && items.length === 0"
|
||||
key="loading"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loadingFiles) }}
|
||||
</div>
|
||||
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
|
||||
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||
{{ formatMessage(messages.busyWarning) }}
|
||||
</Admonition>
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
<FileNavbar
|
||||
:breadcrumbs="breadcrumbSegments"
|
||||
:is-editing="isEditing"
|
||||
:editing-file-name="ctx.editingFile.value?.name"
|
||||
:editing-file-path="ctx.editingFile.value?.path"
|
||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
||||
:search-query="searchQuery"
|
||||
:show-refresh-button="showRefreshButton"
|
||||
:show-install-from-url="ctx.showInstallFromUrl"
|
||||
:base-id="baseId"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
@navigate="navigateToSegment"
|
||||
@navigate-home="() => navigateToSegment(-1)"
|
||||
@prefetch-home="handlePrefetchHome"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@upload-zip="() => {}"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareToMclogs()"
|
||||
@find="() => fileEditorRef?.toggleFind()"
|
||||
/>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||
{{ formatMessage(messages.busyWarning) }}
|
||||
</Admonition>
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
<FileNavbar
|
||||
:breadcrumbs="breadcrumbSegments"
|
||||
:is-editing="isEditing"
|
||||
:editing-file-name="ctx.editingFile.value?.name"
|
||||
:editing-file-path="ctx.editingFile.value?.path"
|
||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
||||
:search-query="searchQuery"
|
||||
:show-refresh-button="showRefreshButton"
|
||||
:show-install-from-url="ctx.showInstallFromUrl"
|
||||
:base-id="baseId"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
@navigate="navigateToSegment"
|
||||
@navigate-home="() => navigateToSegment(-1)"
|
||||
@prefetch-home="handlePrefetchHome"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@upload-zip="() => {}"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareToMclogs()"
|
||||
@find="() => fileEditorRef?.toggleFind()"
|
||||
/>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
<FileUploadDragAndDrop
|
||||
ref="fileUploadRef"
|
||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<FileTableHeader
|
||||
:sort-field="sortField"
|
||||
:sort-desc="sortDescValue"
|
||||
:all-selected="allSelected"
|
||||
:some-selected="someSelected"
|
||||
:is-stuck="isLabelBarStuck"
|
||||
@sort="handleSort"
|
||||
@toggle-all="toggleSelectAll"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredItems.length > 0"
|
||||
ref="virtualListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||
<FileTableRow
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
:write-disabled="isBusy"
|
||||
:write-disabled-tooltip="busyTooltip"
|
||||
@extract="() => handleExtractItem(item)"
|
||||
@delete="() => showDeleteModal(item)"
|
||||
@rename="() => showRenameModal(item)"
|
||||
@download="() => handleDownload(item)"
|
||||
@move="() => showMoveModal(item)"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="() => handleEditFile(item)"
|
||||
@navigate="() => handleNavigateToFolder(item)"
|
||||
@hover="() => handleItemHover(item)"
|
||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||
@toggle-select="() => toggleItemSelection(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && !ctx.error.value"
|
||||
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||
<h3 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.emptyFolderTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.emptyFolderDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FileManagerError
|
||||
v-else-if="ctx.error.value"
|
||||
class="rounded-b-[20px]"
|
||||
:title="formatMessage(messages.errorTitle)"
|
||||
:message="formatMessage(messages.errorMessage)"
|
||||
@refetch="ctx.refresh"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</FileUploadDragAndDrop>
|
||||
</div>
|
||||
<FileEditor
|
||||
v-else
|
||||
ref="fileEditorRef"
|
||||
:file="ctx.editingFile.value"
|
||||
:editor-component="editorComponent"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar :shown="hasUnsavedChanges">
|
||||
<p class="m-0 text-sm font-semibold md:text-base">
|
||||
{{ formatMessage(messages.unsavedChanges) }}
|
||||
</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="fileEditorRef?.revertChanges()">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<FloatingActionBar :shown="selectedItems.size > 0">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button class="!text-primary" @click="deselectAll">
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
<div v-if="!isEditing">
|
||||
<FileUploadDragAndDrop
|
||||
ref="fileUploadRef"
|
||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<FileTableHeader
|
||||
:sort-field="sortField"
|
||||
:sort-desc="sortDescValue"
|
||||
:all-selected="allSelected"
|
||||
:some-selected="someSelected"
|
||||
:is-stuck="isLabelBarStuck"
|
||||
@sort="handleSort"
|
||||
@toggle-all="toggleSelectAll"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredItems.length > 0"
|
||||
ref="virtualListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||
<FileTableRow
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
:write-disabled="isBusy"
|
||||
:write-disabled-tooltip="busyTooltip"
|
||||
@extract="() => handleExtractItem(item)"
|
||||
@delete="() => showDeleteModal(item)"
|
||||
@rename="() => showRenameModal(item)"
|
||||
@download="() => handleDownload(item)"
|
||||
@move="() => showMoveModal(item)"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="() => handleEditFile(item)"
|
||||
@navigate="() => handleNavigateToFolder(item)"
|
||||
@hover="() => handleItemHover(item)"
|
||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||
@toggle-select="() => toggleItemSelection(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && !ctx.error.value"
|
||||
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||
<h3 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.emptyFolderTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.emptyFolderDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FileManagerError
|
||||
v-else-if="ctx.error.value"
|
||||
class="rounded-b-[20px]"
|
||||
:title="formatMessage(messages.errorTitle)"
|
||||
:message="formatMessage(messages.errorMessage)"
|
||||
@refetch="ctx.refresh"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</FileUploadDragAndDrop>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<FileEditor
|
||||
v-else
|
||||
ref="fileEditorRef"
|
||||
:file="ctx.editingFile.value"
|
||||
:editor-component="editorComponent"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<FloatingActionBar :shown="hasUnsavedChanges">
|
||||
<p class="m-0 text-sm font-semibold md:text-base">
|
||||
{{ formatMessage(messages.unsavedChanges) }}
|
||||
</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="fileEditorRef?.revertChanges()">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<FloatingActionBar :shown="selectedItems.size > 0">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button class="!text-primary" @click="deselectAll">
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -216,7 +205,6 @@ import {
|
||||
PackageOpenIcon,
|
||||
RightArrowIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
@@ -256,10 +244,6 @@ import type { FileContextMenuOption, FileItem } from './types'
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingFiles: {
|
||||
id: 'files.layout.loading',
|
||||
defaultMessage: 'Loading files...',
|
||||
},
|
||||
busyWarning: {
|
||||
id: 'files.layout.busy-warning',
|
||||
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
||||
|
||||
@@ -27,122 +27,122 @@
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
||||
<ReadyTransition :pending="backupsReadyPending">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
||||
|
||||
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="groupedBackups.length === 0"
|
||||
key="empty"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<template v-if="!backupsData">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Loading backups...
|
||||
</template>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
type="empty-inbox"
|
||||
heading="No backups yet"
|
||||
description="Create your first backup"
|
||||
<template v-if="backupsData">
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="groupedBackups.length === 0"
|
||||
key="empty"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
class="w-min mx-auto"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<EmptyState
|
||||
type="empty-inbox"
|
||||
heading="No backups yet"
|
||||
description="Create your first backup"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
class="w-min mx-auto"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||
<template v-for="group in groupedBackups" :key="group.label">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
||||
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex w-5 justify-center">
|
||||
<div class="h-full w-px bg-surface-5" />
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
||||
<BackupItem
|
||||
v-for="backup in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-copy-id-action="showCopyIdAction"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation
|
||||
? deleteBackup(backup)
|
||||
: deleteBackupModal?.show(backup)
|
||||
"
|
||||
@retry="() => retryBackup(backup.id)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||
<template v-for="group in groupedBackups" :key="group.label">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
||||
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex w-5 justify-center">
|
||||
<div class="h-full w-px bg-surface-5" />
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
||||
<BackupItem
|
||||
v-for="backup in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-copy-id-action="showCopyIdAction"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
||||
"
|
||||
@retry="() => retryBackup(backup.id)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
>
|
||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
>
|
||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Component } from 'vue'
|
||||
@@ -151,11 +151,13 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
|
||||
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
|
||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -184,13 +186,17 @@ defineEmits(['onDownload'])
|
||||
const backupsQueryKey = ['backups', 'list', serverId]
|
||||
const {
|
||||
data: backupsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: backupsQueryKey,
|
||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const backupsReadyPending = useReadyState({ isLoading, data: backupsData })
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -121,6 +123,8 @@ const contentQuery = useQuery({
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const contentReadyPending = useReadyState(contentQuery)
|
||||
|
||||
const modpackProjectId = computed(() => {
|
||||
const spec = contentQuery.data.value?.modpack?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||
@@ -906,50 +910,52 @@ provideContentManager({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentPageLayout>
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="modpack?.project.title"
|
||||
:modpack-icon-url="modpack?.project.icon_url"
|
||||
enable-toggle
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
/>
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="updatingProjectVersions"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-loader="currentLoader"
|
||||
:current-version-id="
|
||||
updatingModpack
|
||||
? contentQuery.data.value?.modpack?.spec.platform === 'modrinth'
|
||||
? contentQuery.data.value.modpack.spec.version_id
|
||||
: ''
|
||||
: (updatingProject?.version?.id ?? '')
|
||||
"
|
||||
:is-app="false"
|
||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||
:project-icon-url="
|
||||
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@version-hover="handleVersionHover"
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
<ReadyTransition :pending="contentReadyPending">
|
||||
<ContentPageLayout>
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="modpack?.project.title"
|
||||
:modpack-icon-url="modpack?.project.icon_url"
|
||||
enable-toggle
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
/>
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="updatingProjectVersions"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-loader="currentLoader"
|
||||
:current-version-id="
|
||||
updatingModpack
|
||||
? contentQuery.data.value?.modpack?.spec.platform === 'modrinth'
|
||||
? contentQuery.data.value.modpack.spec.version_id
|
||||
: ''
|
||||
: (updatingProject?.version?.id ?? '')
|
||||
"
|
||||
:is-app="false"
|
||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||
:project-icon-url="
|
||||
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@version-hover="handleVersionHover"
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -113,6 +115,8 @@ const {
|
||||
|
||||
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
||||
|
||||
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
|
||||
|
||||
// Prefetching
|
||||
function prefetchDirectory(path: string) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -473,8 +477,10 @@ provideFileManager({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilePageLayout
|
||||
:show-debug-info="props.showDebugInfo"
|
||||
:show-refresh-button="props.showRefreshButton"
|
||||
/>
|
||||
<ReadyTransition :pending="filesReadyPending">
|
||||
<FilePageLayout
|
||||
:show-debug-info="props.showDebugInfo"
|
||||
:show-refresh-button="props.showRefreshButton"
|
||||
/>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
|
||||
@@ -80,115 +80,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition v-else name="fade" mode="out-in">
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="(isLoading || !authReady) && !serverResponse"
|
||||
key="loading"
|
||||
class="flex flex-col gap-4 py-8"
|
||||
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
||||
>
|
||||
<div class="mb-4 text-center">
|
||||
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
||||
<p class="m-0 mt-2 text-secondary">{{ formatMessage(messages.loadingServers) }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
|
||||
>
|
||||
<div class="size-16 rounded-xl bg-button-bg"></div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="h-6 w-48 rounded bg-button-bg"></div>
|
||||
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
|
||||
</div>
|
||||
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.serversTitle) }}
|
||||
</h1>
|
||||
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
|
||||
<StyledInput
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:disabled="showServersListLoading"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder, { count: filteredData.length })"
|
||||
wrapper-class="w-full md:w-72"
|
||||
/>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button @click="openPurchaseModal">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
key="empty"
|
||||
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
||||
>
|
||||
<ServerListEmpty
|
||||
:logged-in="loggedIn"
|
||||
@click-new-server="openPurchaseModal"
|
||||
@click-sign-in="handleSignIn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
<div
|
||||
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
||||
>
|
||||
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.serversTitle) }}
|
||||
</h1>
|
||||
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
|
||||
<StyledInput
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, { count: filteredData.length })
|
||||
"
|
||||
wrapper-class="w-full md:w-72"
|
||||
/>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button @click="openPurchaseModal">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="showServersListLoading" key="loading" class="flex flex-col gap-3">
|
||||
<div
|
||||
v-if="showPollingForNewServers"
|
||||
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
|
||||
>
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
|
||||
<div class="size-16 rounded-xl bg-button-bg"></div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="h-6 w-48 rounded bg-button-bg"></div>
|
||||
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<TransitionGroup
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openPurchaseModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
|
||||
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
|
||||
</div>
|
||||
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
key="empty"
|
||||
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
||||
>
|
||||
<ServerListEmpty
|
||||
:logged-in="loggedIn"
|
||||
@click-new-server="openPurchaseModal"
|
||||
@click-sign-in="handleSignIn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showPollingForNewServers"
|
||||
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
||||
>
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<TransitionGroup
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openPurchaseModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -236,7 +226,6 @@ const route = useRoute()
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const loggedIn = computed(() => !!auth.user.value)
|
||||
const authReady = computed(() => auth.isReady?.value ?? true)
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -266,10 +255,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Contact Modrinth Support',
|
||||
},
|
||||
reloadButton: { id: 'servers.manage.reload-button', defaultMessage: 'Reload' },
|
||||
loadingServers: {
|
||||
id: 'servers.manage.loading-servers',
|
||||
defaultMessage: 'Loading your servers...',
|
||||
},
|
||||
serversTitle: { id: 'servers.manage.servers-title', defaultMessage: 'Modrinth Hosting' },
|
||||
searchPlaceholder: {
|
||||
id: 'servers.manage.search-placeholder',
|
||||
@@ -509,7 +494,7 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
isLoading,
|
||||
isPending: serversQueryPending,
|
||||
} = useQuery({
|
||||
queryKey: ['servers'],
|
||||
queryFn: async () => {
|
||||
@@ -556,6 +541,9 @@ const {
|
||||
|
||||
const hasError = computed(() => loggedIn.value && !!fetchError.value)
|
||||
|
||||
/** Logged-in initial fetch: avoid treating "no data yet" as an empty server list. */
|
||||
const showServersListLoading = computed(() => loggedIn.value && serversQueryPending.value)
|
||||
|
||||
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!loggedIn.value || !serverResponse.value) return []
|
||||
return serverResponse.value.servers
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -95,14 +95,6 @@
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</div>
|
||||
<!-- Loading state (before serverData arrives) -->
|
||||
<div
|
||||
v-else-if="!serverData && !serverError"
|
||||
class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 relative bottom-12"
|
||||
>
|
||||
<LoaderCircleIcon class="size-16 animate-spin" />
|
||||
<span class="text-secondary">{{ formatMessage(loadingMessages.loadingServerPanel) }}</span>
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
@@ -120,14 +112,7 @@
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="revealState === 'pending' && !isOnboarding"
|
||||
class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 relative bottom-12"
|
||||
>
|
||||
<LoaderCircleIcon class="size-16 animate-spin" />
|
||||
<span class="text-secondary">{{ formatMessage(loadingMessages.loadingServerPanel) }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template v-if="revealState !== 'pending' || isOnboarding">
|
||||
<ServerManageHeader
|
||||
v-if="!isOnboarding"
|
||||
class="server-stagger-item"
|
||||
@@ -463,7 +448,9 @@ import {
|
||||
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
||||
import {
|
||||
useDebugLogger,
|
||||
useLoadingBarToken,
|
||||
useModrinthServersConsole,
|
||||
useReadyState,
|
||||
useServerImage,
|
||||
useServerProject,
|
||||
} from '#ui/composables'
|
||||
@@ -536,13 +523,6 @@ const props = withDefaults(
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const loadingMessages = defineMessages({
|
||||
loadingServerPanel: {
|
||||
id: 'servers.manage.loading.serverPanel',
|
||||
defaultMessage: 'Loading your server panel...',
|
||||
},
|
||||
})
|
||||
|
||||
const leaveMessages = defineMessages({
|
||||
uploadInProgress: {
|
||||
id: 'servers.manage.confirm-leave.upload-in-progress',
|
||||
@@ -569,6 +549,9 @@ const settingsHintMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
// disabled, keeping the animation logic cos it's really nice and we might want to re-enable in future
|
||||
const DISABLE_LOADING_ANIM = true
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
@@ -599,11 +582,17 @@ function dismissSettingsHint() {
|
||||
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
|
||||
const { data: serverData, error: serverQueryError } = useQuery({
|
||||
const {
|
||||
data: serverData,
|
||||
error: serverQueryError,
|
||||
isLoading: serverLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['servers', 'detail', props.serverId],
|
||||
queryFn: () => client.archon.servers_v0.get(props.serverId)!,
|
||||
})
|
||||
|
||||
useLoadingBarToken(useReadyState({ isLoading: serverLoading, data: serverData }))
|
||||
|
||||
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
|
||||
if (!serverData.value) return
|
||||
queryClient.setQueryData(['servers', 'detail', props.serverId], {
|
||||
@@ -817,7 +806,7 @@ log('canReveal initial', {
|
||||
})
|
||||
|
||||
const revealState = ref<'pending' | 'revealing' | 'visible'>(
|
||||
canReveal.value ? 'visible' : 'pending',
|
||||
DISABLE_LOADING_ANIM || canReveal.value ? 'visible' : 'pending',
|
||||
)
|
||||
log('revealState initial', revealState.value)
|
||||
|
||||
@@ -826,11 +815,15 @@ const REVEAL_TOTAL_MS = 2 * 80 + 400
|
||||
watch(canReveal, (ready) => {
|
||||
log('canReveal changed', { ready, revealState: revealState.value })
|
||||
if (ready && revealState.value === 'pending') {
|
||||
revealState.value = 'revealing'
|
||||
setTimeout(() => {
|
||||
if (DISABLE_LOADING_ANIM) {
|
||||
revealState.value = 'visible'
|
||||
log('revealState -> visible')
|
||||
}, REVEAL_TOTAL_MS)
|
||||
} else {
|
||||
revealState.value = 'revealing'
|
||||
setTimeout(() => {
|
||||
revealState.value = 'visible'
|
||||
log('revealState -> visible')
|
||||
}, REVEAL_TOTAL_MS)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -644,9 +644,6 @@
|
||||
"files.layout.extraction-started-title": {
|
||||
"defaultMessage": "Extraction started"
|
||||
},
|
||||
"files.layout.loading": {
|
||||
"defaultMessage": "Loading files..."
|
||||
},
|
||||
"files.layout.selected-count": {
|
||||
"defaultMessage": "{count} selected"
|
||||
},
|
||||
@@ -2876,12 +2873,6 @@
|
||||
"servers.manage.handle-error.title": {
|
||||
"defaultMessage": "An error occurred"
|
||||
},
|
||||
"servers.manage.loading-servers": {
|
||||
"defaultMessage": "Loading your servers..."
|
||||
},
|
||||
"servers.manage.loading.serverPanel": {
|
||||
"defaultMessage": "Loading your server panel..."
|
||||
},
|
||||
"servers.manage.new-server-button": {
|
||||
"defaultMessage": "New server"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './file-picker'
|
||||
export * from './hosting-purchase-intent'
|
||||
export * from './i18n'
|
||||
export * from './instance-import'
|
||||
export * from './loading-state'
|
||||
export * from './modal-behavior'
|
||||
export * from './page-context'
|
||||
export * from './popup-notifications'
|
||||
|
||||
27
packages/ui/src/providers/loading-state.ts
Normal file
27
packages/ui/src/providers/loading-state.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createContext } from './create-context'
|
||||
|
||||
/**
|
||||
* Cross-platform loading-state contract injected by the host app.
|
||||
* Consumed by the shared `LoadingBar` and `ReadyTransition` components.
|
||||
*/
|
||||
export interface LoadingStateProvider {
|
||||
/** True iff at least one active load token is registered. */
|
||||
readonly pending: Readonly<Ref<boolean>>
|
||||
/** Host-level kill switch (e.g. disable the bar during a splash screen). */
|
||||
readonly barEnabled: Readonly<Ref<boolean>>
|
||||
/** Begin a tracked load. Returns a unique token; pair with `end(token)`. */
|
||||
begin(): symbol
|
||||
/** End a previously-begun load. Idempotent — unknown or repeat tokens are silently ignored. */
|
||||
end(token: symbol): void
|
||||
/** Fire a synthetic load that auto-releases after `durationMs` (default 500ms). For manual-refresh buttons. */
|
||||
beginManual(durationMs?: number): void
|
||||
/** Toggle the bar at the host level. */
|
||||
setEnabled(enabled: boolean): void
|
||||
}
|
||||
|
||||
export const [injectLoadingState, provideLoadingState] = createContext<LoadingStateProvider>(
|
||||
'root',
|
||||
'loadingState',
|
||||
)
|
||||
97
packages/ui/src/stories/base/LoadingBar.stories.ts
Normal file
97
packages/ui/src/stories/base/LoadingBar.stories.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import LoadingBar from '../../components/base/LoadingBar.vue'
|
||||
import { createLoadingStateCore } from '../../composables/use-loading-state-core'
|
||||
import { provideLoadingState } from '../../providers/loading-state'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/LoadingBar',
|
||||
component: LoadingBar,
|
||||
} satisfies Meta<typeof LoadingBar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Idle: Story = {
|
||||
render: () => ({
|
||||
components: { LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="relative h-32 w-full">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary">Loading bar is idle (no active tokens).</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const SinglePending: Story = {
|
||||
render: () => ({
|
||||
components: { LoadingBar },
|
||||
setup() {
|
||||
const core = createLoadingStateCore()
|
||||
provideLoadingState(core)
|
||||
onMounted(() => {
|
||||
core.begin()
|
||||
})
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="relative h-32 w-full">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary">One token registered — bar fills to 100% over 1s.</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const StackedPending: Story = {
|
||||
render: () => ({
|
||||
components: { LoadingBar },
|
||||
setup() {
|
||||
const core = createLoadingStateCore()
|
||||
provideLoadingState(core)
|
||||
const tokens: symbol[] = []
|
||||
onMounted(() => {
|
||||
tokens.push(core.begin())
|
||||
tokens.push(core.begin())
|
||||
setTimeout(() => core.end(tokens[0]!), 1500)
|
||||
setTimeout(() => core.end(tokens[1]!), 3000)
|
||||
})
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="relative h-32 w-full">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary">Two tokens. First releases at 1.5s, second at 3s — bar stays visible until both end.</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ManualRefresh: Story = {
|
||||
render: () => ({
|
||||
components: { LoadingBar },
|
||||
setup() {
|
||||
const core = createLoadingStateCore()
|
||||
provideLoadingState(core)
|
||||
const last = ref<string>('idle')
|
||||
function trigger() {
|
||||
core.beginManual(800)
|
||||
last.value = `beginManual(800) at ${new Date().toLocaleTimeString()}`
|
||||
}
|
||||
return { trigger, last }
|
||||
},
|
||||
template: `
|
||||
<div class="relative h-32 w-full">
|
||||
<LoadingBar />
|
||||
<button class="rounded bg-button-bg px-3 py-2 text-contrast" @click="trigger">Manual refresh</button>
|
||||
<p class="text-secondary mt-2">{{ last }}</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
151
packages/ui/src/stories/base/ReadyTransition.stories.ts
Normal file
151
packages/ui/src/stories/base/ReadyTransition.stories.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import LoadingBar from '../../components/base/LoadingBar.vue'
|
||||
import ReadyTransition from '../../components/base/ReadyTransition.vue'
|
||||
import { createLoadingStateCore } from '../../composables/use-loading-state-core'
|
||||
import { provideLoadingState } from '../../providers/loading-state'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/ReadyTransition',
|
||||
component: ReadyTransition,
|
||||
} satisfies Meta<typeof ReadyTransition>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Idle: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
return { pending: ref(false) }
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<LoadingBar />
|
||||
<ReadyTransition :pending="pending">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Slot content (already ready).</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
/** Pending false from mount — no enter fade (cache-hit path). */
|
||||
export const CacheHit: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
return { pending: ref(false) }
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary mb-4">pending stays false — content should appear with no fade-in.</p>
|
||||
<ReadyTransition :pending="pending">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Cached content visible immediately.</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
/** Cold load: pending true then false — fade-in runs. */
|
||||
export const ColdLoad: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
const pending = ref(true)
|
||||
onMounted(() => {
|
||||
setTimeout(() => (pending.value = false), 600)
|
||||
})
|
||||
return { pending }
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary mb-4">Pending 600ms then ready — content fades in; bar runs while pending.</p>
|
||||
<ReadyTransition :pending="pending">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Content after cold load.</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const PendingThenReady: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
const pending = ref(true)
|
||||
onMounted(() => {
|
||||
setTimeout(() => (pending.value = false), 2000)
|
||||
})
|
||||
return { pending }
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary mb-4">Pending for 2s, then content fades in. Bar runs at top.</p>
|
||||
<ReadyTransition :pending="pending">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Slot content revealed.</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const StackedTwoTransitions: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
const a = ref(true)
|
||||
const b = ref(true)
|
||||
onMounted(() => {
|
||||
setTimeout(() => (a.value = false), 1500)
|
||||
setTimeout(() => (b.value = false), 3000)
|
||||
})
|
||||
return { a, b }
|
||||
},
|
||||
template: `
|
||||
<div class="relative grid gap-4">
|
||||
<LoadingBar />
|
||||
<ReadyTransition :pending="a">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Panel A (ready at 1.5s).</div>
|
||||
</ReadyTransition>
|
||||
<ReadyTransition :pending="b">
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Panel B (ready at 3s).</div>
|
||||
</ReadyTransition>
|
||||
<p class="text-secondary">Bar stays visible until BOTH panels resolve.</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Silent: Story = {
|
||||
render: () => ({
|
||||
components: { ReadyTransition, LoadingBar },
|
||||
setup() {
|
||||
provideLoadingState(createLoadingStateCore())
|
||||
const pending = ref(true)
|
||||
onMounted(() => {
|
||||
setTimeout(() => (pending.value = false), 1500)
|
||||
})
|
||||
return { pending }
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<LoadingBar />
|
||||
<p class="text-secondary mb-4">silent=true — fades locally but does NOT raise the loading bar.</p>
|
||||
<ReadyTransition :pending="pending" silent>
|
||||
<div class="rounded bg-bg-raised p-4 text-contrast">Silent slot content.</div>
|
||||
</ReadyTransition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user