feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53)
This commit is contained in:
+4
-7
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user