feat: shared loading state + cleanup loading state management (#5835)

* feat: implement shared loading bar component and polished loading states across the app

* feat: align loading states + ensureQueryData changes

* fix: lint + bugs

* fix: skeleton for manage servers page

* fix: merge conflict fix
This commit is contained in:
Calum H.
2026-04-18 19:46:39 +01:00
committed by GitHub
parent 3e32901737
commit 176d4301c3
47 changed files with 2063 additions and 1371 deletions

View File

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

View File

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