Skip to content

Commit 700b6d6

Browse files
author
Pascal Klesse
committed
feat: add skeleton loading states to improve perceived performance
Add Skeleton component and implement loading states across project pages, container details, backup, builds, logs, stats, and sources views. Uses lazy loading with status checks to show skeleton placeholders during data fetching.
1 parent d83dad6 commit 700b6d6

File tree

8 files changed

+323
-238
lines changed

8 files changed

+323
-238
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script setup lang="ts">
2+
// ============================================================================
3+
// Props
4+
// ============================================================================
5+
withDefaults(
6+
defineProps<{
7+
class?: string;
8+
}>(),
9+
{
10+
class: '',
11+
},
12+
);
13+
</script>
14+
15+
<template>
16+
<div class="animate-pulse rounded-md bg-foreground/10" :class="$props.class"></div>
17+
</template>

projects/app/src/pages/index.vue

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,32 @@ definePageMeta({
1919
breadcrumbs: 'Projects',
2020
});
2121
22-
const { data, refresh } = await useAsyncFindProjectsQuery([
23-
'id',
24-
'identifier',
25-
'name',
26-
'healthStatus',
27-
{ subscribers: ['id'] },
28-
{
29-
containers: [
30-
'id',
31-
'kind',
32-
'name',
33-
'updatedAt',
34-
'branch',
35-
'url',
36-
'status',
37-
'repositoryId',
38-
'healthStatus',
39-
{ lastBuild: ['id', 'createdAt'] },
40-
],
41-
},
42-
]);
43-
const projects = computed(() => data.value || []);
22+
const { data, refresh, status } = await useAsyncFindProjectsQuery(
23+
[
24+
'id',
25+
'identifier',
26+
'name',
27+
'healthStatus',
28+
{ subscribers: ['id'] },
29+
{
30+
containers: [
31+
'id',
32+
'kind',
33+
'name',
34+
'updatedAt',
35+
'branch',
36+
'url',
37+
'status',
38+
'repositoryId',
39+
'healthStatus',
40+
{ lastBuild: ['id', 'createdAt'] },
41+
],
42+
},
43+
],
44+
false,
45+
{ lazy: true },
46+
);
47+
const projects = computed<Project[]>(() => data.value || []);
4448
const expanded = ref<string[]>([]);
4549
const { open } = useModal();
4650
const { open: openMenu } = useContextMenu();
@@ -272,7 +276,12 @@ function showProjectContextMenu(project: Project) {
272276
<template>
273277
<div class="pt-[63px] w-full">
274278
<ClientOnly>
275-
<List>
279+
<div v-if="status === 'pending'" class="flex flex-col gap-2 p-4">
280+
<Skeleton class="h-14 w-full" />
281+
<Skeleton class="h-14 w-full" />
282+
<Skeleton class="h-14 w-full" />
283+
</div>
284+
<List v-else>
276285
<ListItem
277286
v-for="project of projects"
278287
:key="project.id"

projects/app/src/pages/projects/[id]/[containerId].vue

Lines changed: 79 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,21 @@ const containerId = ref<string>(route.params.containerId ? (route.params.contain
1717
const showActions = ref(false);
1818
const { currentUserState: user } = useAuthState();
1919
20-
const { data: project } = await useAsyncGetProjectQuery({ id: projectId.value }, ['name']);
21-
const projectName = computed(() => project.value?.name || null);
20+
const { data: project, status: projectStatus } = await useAsyncGetProjectQuery(
21+
{ id: projectId.value },
22+
['name'],
23+
false,
24+
{ lazy: true },
25+
);
26+
const projectName = computed<null | string>(() => project.value?.name || null);
2227
23-
const { data, refresh } = await useAsyncGetContainerQuery({ id: containerId.value }, null);
24-
const container = computed(() => data.value || null);
28+
const {
29+
data,
30+
refresh,
31+
status: containerStatus,
32+
} = await useAsyncGetContainerQuery({ id: containerId.value }, null, false, { lazy: true });
33+
const container = computed<Container | null>(() => data.value || null);
34+
const isLoading = computed<boolean>(() => projectStatus.value === 'pending' || containerStatus.value === 'pending');
2535
2636
const stoppable = computed(
2737
() =>
@@ -84,7 +94,12 @@ const tabs = computed(() => [
8494
8595
let healthyState: any = null;
8696
if (container.value?.healthCheckCmd || container.value?.customDockerfile?.includes('HEALTHCHECK')) {
87-
const { data: healthData } = await useAsyncGetContainerHealthStatusQuery({ id: containerId.value });
97+
const { data: healthData } = await useAsyncGetContainerHealthStatusQuery(
98+
{ id: containerId.value },
99+
undefined,
100+
false,
101+
{ lazy: true },
102+
);
88103
healthyState = computed(() => healthData.value);
89104
}
90105
@@ -187,61 +202,68 @@ async function get1PasswordContent() {
187202

188203
<template>
189204
<div class="w-full flex flex-col h-full">
190-
<div class="mt-10 w-full sticky top-[64px] bg-background z-[1]">
191-
<div class="flex items-center justify-between px-4 sm:px-6 lg:px-5">
192-
<span class="line-clamp-1 text-secondary-100">{{ projectName }} / {{ container?.name }}</span>
193-
<div class="flex items-center gap-3">
194-
<div class="hidden md:inline">
195-
<ClientOnly>
196-
<onepassword-save-button
197-
data-onepassword-type="login"
198-
:value="get1PasswordContent()"
199-
lang="en"
200-
class="black"
201-
data-theme="dark"
202-
padding="compact"
203-
/>
204-
</ClientOnly>
205-
</div>
206-
<HealthyBadge v-if="healthyState" :state="healthyState" />
207-
<StatusBadge :status="container?.status!" size="MD" badge-style="STATUS" />
208-
<div v-if="user?.roles?.includes('admin')" class="inline md:hidden">
209-
<SmallButton @click="showActions = !showActions">
210-
<Icon name="bi:three-dots-vertical" size="20" />
211-
</SmallButton>
212-
</div>
213-
<div
214-
class="items-center gap-3"
215-
:class="
216-
showActions
217-
? 'absolute right-5 top-16 z-[55] mt-2 origin-top-right rounded-md shadow-lg flex flex-col gap-2 transition-all duration-200'
218-
: 'hidden md:flex'
219-
"
220-
>
221-
<SmallButton v-if="stoppable" tooltip="Stop" placement="bottom" @click="stop(container.id!)">
222-
<Icon name="ic:baseline-stop" class="text-red-500" size="20" />
223-
</SmallButton>
224-
<SmallButton v-if="deployable" tooltip="Deploy" placement="bottom" @click="deploy(container.id!)">
225-
<Icon name="ic:baseline-play-arrow" class="dark:text-primary-500" size="20" />
226-
</SmallButton>
227-
<SmallButton v-if="redeployable" tooltip="Redeploy" placement="bottom" @click="deploy(container.id!)">
228-
<Icon name="ic:baseline-restart-alt" class="dark:text-primary-500" size="20" />
229-
</SmallButton>
230-
<SmallButton
231-
v-if="container?.status === ContainerStatus.DEPLOYED && container.url"
232-
tooltip="Open"
233-
placement="bottom"
234-
@click="openContainerUrl(container)"
205+
<div v-if="isLoading" class="mt-10 p-4 flex flex-col gap-4">
206+
<Skeleton class="h-10 w-full" />
207+
<Skeleton class="h-12 w-full" />
208+
<Skeleton class="h-64 w-full" />
209+
</div>
210+
<template v-else>
211+
<div class="mt-10 w-full sticky top-[64px] bg-background z-[1]">
212+
<div class="flex items-center justify-between px-4 sm:px-6 lg:px-5">
213+
<span class="line-clamp-1 text-secondary-100">{{ projectName }} / {{ container?.name }}</span>
214+
<div class="flex items-center gap-3">
215+
<div class="hidden md:inline">
216+
<ClientOnly>
217+
<onepassword-save-button
218+
data-onepassword-type="login"
219+
:value="get1PasswordContent()"
220+
lang="en"
221+
class="black"
222+
data-theme="dark"
223+
padding="compact"
224+
/>
225+
</ClientOnly>
226+
</div>
227+
<HealthyBadge v-if="healthyState" :state="healthyState" />
228+
<StatusBadge :status="container?.status!" size="MD" badge-style="STATUS" />
229+
<div v-if="user?.roles?.includes('admin')" class="inline md:hidden">
230+
<SmallButton @click="showActions = !showActions">
231+
<Icon name="bi:three-dots-vertical" size="20" />
232+
</SmallButton>
233+
</div>
234+
<div
235+
class="items-center gap-3"
236+
:class="
237+
showActions
238+
? 'absolute right-5 top-16 z-[55] mt-2 origin-top-right rounded-md shadow-lg flex flex-col gap-2 transition-all duration-200'
239+
: 'hidden md:flex'
240+
"
235241
>
236-
<Icon name="ic:twotone-open-in-new" class="hover:text-primary-500" size="20" />
237-
</SmallButton>
242+
<SmallButton v-if="stoppable" tooltip="Stop" placement="bottom" @click="stop(container.id!)">
243+
<Icon name="ic:baseline-stop" class="text-red-500" size="20" />
244+
</SmallButton>
245+
<SmallButton v-if="deployable" tooltip="Deploy" placement="bottom" @click="deploy(container.id!)">
246+
<Icon name="ic:baseline-play-arrow" class="dark:text-primary-500" size="20" />
247+
</SmallButton>
248+
<SmallButton v-if="redeployable" tooltip="Redeploy" placement="bottom" @click="deploy(container.id!)">
249+
<Icon name="ic:baseline-restart-alt" class="dark:text-primary-500" size="20" />
250+
</SmallButton>
251+
<SmallButton
252+
v-if="container?.status === ContainerStatus.DEPLOYED && container.url"
253+
tooltip="Open"
254+
placement="bottom"
255+
@click="openContainerUrl(container)"
256+
>
257+
<Icon name="ic:twotone-open-in-new" class="hover:text-primary-500" size="20" />
258+
</SmallButton>
259+
</div>
238260
</div>
239261
</div>
262+
<Tabs :tabs="tabs" />
240263
</div>
241-
<Tabs :tabs="tabs" />
242-
</div>
243-
<div class="h-full w-full overflow-hidden p-5 mt-5">
244-
<NuxtPage />
245-
</div>
264+
<div class="h-full w-full overflow-hidden p-5 mt-5">
265+
<NuxtPage />
266+
</div>
267+
</template>
246268
</div>
247269
</template>

0 commit comments

Comments
 (0)