feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53)

This commit is contained in:
2026-06-08 06:50:57 +02:00
parent d0da77a004
commit 0d4026a968
8 changed files with 59 additions and 66 deletions
+4 -7
View File
@@ -12,6 +12,7 @@ import { VocabulariesPage } from "./vocab/vocabularies-page";
import { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
import { AuthoritiesPage } from "./authorities/authorities-page";
import { FormSkeleton, ListSkeleton } from "@/components/ui/skeletons";
const ObjectNewPage = lazy(() =>
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })),
@@ -25,10 +26,6 @@ const FieldsPage = lazy(() =>
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
);
function FormFallback() {
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading</div>;
}
const router = createBrowserRouter(
createRoutesFromElements(
<>
@@ -38,7 +35,7 @@ const router = createBrowserRouter(
<Route
path="/objects/new"
element={
<Suspense fallback={<FormFallback />}>
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectNewPage />
</Suspense>
}
@@ -48,7 +45,7 @@ const router = createBrowserRouter(
<Route
path=":id/edit"
element={
<Suspense fallback={<FormFallback />}>
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectEditForm />
</Suspense>
}
@@ -67,7 +64,7 @@ const router = createBrowserRouter(
<Route
path="/fields"
element={
<Suspense fallback={<FormFallback />}>
<Suspense fallback={<ListSkeleton />}>
<FieldsPage />
</Suspense>
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { useMe } from "../api/queries";
import { AppShellSkeleton } from "@/components/ui/skeletons";
export function RequireAuth() {
const { data: user, isLoading } = useMe();
if (isLoading) return <div role="status" aria-label="loading" />;
if (isLoading) return <AppShellSkeleton />;
if (!user) return <Navigate to="/login" replace />;
+6 -4
View File
@@ -7,6 +7,7 @@ import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
import { ListSkeleton } from "@/components/ui/skeletons";
import { AuthorityRow } from "./authority-row";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
@@ -68,20 +69,21 @@ export function AuthoritiesPage() {
))}
</div>
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isLoading && (
<li className="text-sm text-muted-foreground"></li>
)}
{isError && (
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
)}
{!isLoading && !isError && authorities?.length === 0 && (
{!isError && authorities?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)}
{authorities?.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))}
</ul>
)}
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">
+2 -10
View File
@@ -4,7 +4,7 @@ import type { components } from "../api/schema";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { ListSkeleton } from "@/components/ui/skeletons";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
@@ -20,15 +20,7 @@ export function FieldList({
const deleteField = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
if (isLoading) {
return (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
);
}
if (isLoading) return <ListSkeleton rows={6} />;
if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>;
if (!data || data.length === 0)
+2 -1
View File
@@ -6,6 +6,7 @@ import type { components } from "../api/schema";
import { useObject, useUpdateObject, useSetFields, FieldRejection } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
import { FormSkeleton } from "@/components/ui/skeletons";
type AdminObjectView = components["schemas"]["AdminObjectView"];
@@ -15,7 +16,7 @@ export function ObjectEditForm() {
const { data: object, isLoading } = useObject(id!);
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (isLoading) return <FormSkeleton />;
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
+2 -6
View File
@@ -7,7 +7,7 @@ import { useDebouncedValue } from "../lib/use-debounced-value";
import { SearchResultRow } from "./search-result-row";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ListSkeleton } from "@/components/ui/skeletons";
const VIS = ["all", "draft", "internal", "public"] as const;
@@ -84,11 +84,7 @@ export function SearchPanel() {
{!hasQuery && <p className="p-4 text-sm text-muted-foreground">{t("search.prompt")}</p>}
{hasQuery && search.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
<ListSkeleton rows={5} rowClassName="h-12 w-full" />
)}
{hasQuery && search.isError && (
+5 -3
View File
@@ -7,6 +7,7 @@ import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ListSkeleton } from "@/components/ui/skeletons";
export function VocabularyList() {
const { t } = useTranslation();
@@ -50,10 +51,10 @@ export function VocabularyList() {
</p>
)}
</form>
{isLoading ? (
<ListSkeleton className="flex-1 overflow-auto" />
) : (
<ul className="flex-1 overflow-auto">
{isLoading && (
<li className="p-3 text-sm text-muted-foreground"></li>
)}
{isError && (
<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>
)}
@@ -117,6 +118,7 @@ export function VocabularyList() {
</li>
))}
</ul>
)}
</div>
);
}
+6 -4
View File
@@ -10,6 +10,7 @@ import { TermRow } from "./term-row";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ListSkeleton } from "@/components/ui/skeletons";
type LabelInput = components["schemas"]["LabelInput"];
@@ -62,20 +63,21 @@ export function VocabularyTerms() {
<div className="mb-2 label-caption">
{t("vocab.terms")}
</div>
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isLoading && (
<li className="text-sm text-muted-foreground"></li>
)}
{isError && (
<li className="text-sm text-destructive">{t("vocab.loadError")}</li>
)}
{!isLoading && !isError && terms?.length === 0 && (
{!isError && terms?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
)}
{terms?.map((term) => (
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
))}
</ul>
)}
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
<LabelEditor value={labels} onChange={setLabels} />