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 { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
import { AuthoritiesPage } from "./authorities/authorities-page"; import { AuthoritiesPage } from "./authorities/authorities-page";
import { FormSkeleton, ListSkeleton } from "@/components/ui/skeletons";
const ObjectNewPage = lazy(() => const ObjectNewPage = lazy(() =>
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })), 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 })), 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( const router = createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
<> <>
@@ -38,7 +35,7 @@ const router = createBrowserRouter(
<Route <Route
path="/objects/new" path="/objects/new"
element={ element={
<Suspense fallback={<FormFallback />}> <Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectNewPage /> <ObjectNewPage />
</Suspense> </Suspense>
} }
@@ -48,7 +45,7 @@ const router = createBrowserRouter(
<Route <Route
path=":id/edit" path=":id/edit"
element={ element={
<Suspense fallback={<FormFallback />}> <Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectEditForm /> <ObjectEditForm />
</Suspense> </Suspense>
} }
@@ -67,7 +64,7 @@ const router = createBrowserRouter(
<Route <Route
path="/fields" path="/fields"
element={ element={
<Suspense fallback={<FormFallback />}> <Suspense fallback={<ListSkeleton />}>
<FieldsPage /> <FieldsPage />
</Suspense> </Suspense>
} }
+2 -1
View File
@@ -1,11 +1,12 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { useMe } from "../api/queries"; import { useMe } from "../api/queries";
import { AppShellSkeleton } from "@/components/ui/skeletons";
export function RequireAuth() { export function RequireAuth() {
const { data: user, isLoading } = useMe(); 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 />; if (!user) return <Navigate to="/login" replace />;
+16 -14
View File
@@ -7,6 +7,7 @@ import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor"; import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title"; import { PageTitle } from "@/components/ui/page-title";
import { ListSkeleton } from "@/components/ui/skeletons";
import { AuthorityRow } from "./authority-row"; import { AuthorityRow } from "./authority-row";
import { useDocumentTitle } from "../lib/use-document-title"; import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb"; import { useBreadcrumb } from "../shell/use-breadcrumb";
@@ -68,20 +69,21 @@ export function AuthoritiesPage() {
))} ))}
</div> </div>
<ul className="mb-4"> {isLoading ? (
{isLoading && ( <ListSkeleton className="mb-4" rows={5} />
<li className="text-sm text-muted-foreground"></li> ) : (
)} <ul className="mb-4">
{isError && ( {isError && (
<li className="text-sm text-destructive">{t("authorities.loadError")}</li> <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> <li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)} )}
{authorities?.map((a) => ( {authorities?.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} /> <AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))} ))}
</ul> </ul>
)}
<form onSubmit={onCreate} className="space-y-2 border-t pt-3"> <form onSubmit={onCreate} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium"> <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 { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels"; import { labelText } from "../lib/labels";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Skeleton } from "@/components/ui/skeleton"; import { ListSkeleton } from "@/components/ui/skeletons";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
@@ -20,15 +20,7 @@ export function FieldList({
const deleteField = useDeleteFieldDefinition(); const deleteField = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const lang = i18n.language.startsWith("sv") ? "sv" : "en";
if (isLoading) { if (isLoading) return <ListSkeleton rows={6} />;
return (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
);
}
if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>; if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>;
if (!data || data.length === 0) 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 { useObject, useUpdateObject, useSetFields, FieldRejection } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb"; import { useBreadcrumb } from "../shell/use-breadcrumb";
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form"; import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
import { FormSkeleton } from "@/components/ui/skeletons";
type AdminObjectView = components["schemas"]["AdminObjectView"]; type AdminObjectView = components["schemas"]["AdminObjectView"];
@@ -15,7 +16,7 @@ export function ObjectEditForm() {
const { data: object, isLoading } = useObject(id!); 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>; 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 { SearchResultRow } from "./search-result-row";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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; 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 && <p className="p-4 text-sm text-muted-foreground">{t("search.prompt")}</p>}
{hasQuery && search.isLoading && ( {hasQuery && search.isLoading && (
<div className="space-y-2 p-3"> <ListSkeleton rows={5} rowClassName="h-12 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)} )}
{hasQuery && search.isError && ( {hasQuery && search.isError && (
+15 -13
View File
@@ -7,6 +7,7 @@ import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ListSkeleton } from "@/components/ui/skeletons";
export function VocabularyList() { export function VocabularyList() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,17 +51,17 @@ export function VocabularyList() {
</p> </p>
)} )}
</form> </form>
<ul className="flex-1 overflow-auto"> {isLoading ? (
{isLoading && ( <ListSkeleton className="flex-1 overflow-auto" />
<li className="p-3 text-sm text-muted-foreground"></li> ) : (
)} <ul className="flex-1 overflow-auto">
{isError && ( {isError && (
<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li> <li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>
)} )}
{data?.length === 0 && ( {data?.length === 0 && (
<li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li> <li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li>
)} )}
{data?.map((v) => ( {data?.map((v) => (
<li key={v.id} className="flex items-center gap-1 border-b pr-2"> <li key={v.id} className="flex items-center gap-1 border-b pr-2">
{editingId === v.id ? ( {editingId === v.id ? (
<form <form
@@ -115,8 +116,9 @@ export function VocabularyList() {
</> </>
)} )}
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
); );
} }
+16 -14
View File
@@ -10,6 +10,7 @@ import { TermRow } from "./term-row";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ListSkeleton } from "@/components/ui/skeletons";
type LabelInput = components["schemas"]["LabelInput"]; type LabelInput = components["schemas"]["LabelInput"];
@@ -62,20 +63,21 @@ export function VocabularyTerms() {
<div className="mb-2 label-caption"> <div className="mb-2 label-caption">
{t("vocab.terms")} {t("vocab.terms")}
</div> </div>
<ul className="mb-4"> {isLoading ? (
{isLoading && ( <ListSkeleton className="mb-4" rows={5} />
<li className="text-sm text-muted-foreground"></li> ) : (
)} <ul className="mb-4">
{isError && ( {isError && (
<li className="text-sm text-destructive">{t("vocab.loadError")}</li> <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> <li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
)} )}
{terms?.map((term) => ( {terms?.map((term) => (
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} /> <TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
))} ))}
</ul> </ul>
)}
<form onSubmit={onAdd} className="space-y-2 border-t pt-3"> <form onSubmit={onAdd} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">{t("vocab.addTerm")}</div> <div className="text-sm font-medium">{t("vocab.addTerm")}</div>
<LabelEditor value={labels} onChange={setLabels} /> <LabelEditor value={labels} onChange={setLabels} />