diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx
index d351959..e0d53f0 100644
--- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx
+++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx
@@ -142,7 +142,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
{item.label}
diff --git a/ApiCore-Frontend/src/components/empty-state.tsx b/ApiCore-Frontend/src/components/empty-state.tsx
index faae328..221c77d 100644
--- a/ApiCore-Frontend/src/components/empty-state.tsx
+++ b/ApiCore-Frontend/src/components/empty-state.tsx
@@ -1,21 +1,25 @@
+import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
+ /** The one obvious next step, e.g. a link button "Erstes Volk anlegen". */
+ action?: ReactNode;
}
-export function EmptyState({ icon: Icon, title, description }: EmptyStateProps) {
+export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
-
+
-
+
{title}
{description ?
{description}
: null}
+ {action ?
{action}
: null}
);
}
diff --git a/ApiCore-Frontend/src/components/query-state.spec.tsx b/ApiCore-Frontend/src/components/query-state.spec.tsx
new file mode 100644
index 0000000..831df46
--- /dev/null
+++ b/ApiCore-Frontend/src/components/query-state.spec.tsx
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryState } from "./query-state";
+
+const base = { isLoading: false, isError: false, isFetching: false, refetch: vi.fn() };
+
+describe("QueryState", () => {
+ it("never shows the empty state for a failed load, and offers a retry", async () => {
+ const refetch = vi.fn();
+ render(
+
Noch keine Völker}
+ >
+ {() => Daten
}
+ ,
+ );
+ expect(screen.queryByText("Noch keine Völker")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ it("shows the empty state for an empty list", () => {
+ render(
+
Noch keine Völker}>
+ {() => Daten
}
+ ,
+ );
+ expect(screen.getByText("Noch keine Völker")).toBeInTheDocument();
+ });
+
+ it("renders the data", () => {
+ render(
+
+ {(data) => {data.length} Volk
}
+ ,
+ );
+ expect(screen.getByText("1 Volk")).toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/components/query-state.tsx b/ApiCore-Frontend/src/components/query-state.tsx
new file mode 100644
index 0000000..72d5cdc
--- /dev/null
+++ b/ApiCore-Frontend/src/components/query-state.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { ReactNode } from "react";
+import { AlertCircle, RotateCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface ErrorStateProps {
+ /** What could not be loaded, e.g. "Völker". */
+ what: string;
+ onRetry?: () => void;
+ isRetrying?: boolean;
+}
+
+/**
+ * A failed load says what failed and offers the next step right there -
+ * never a dead end, and never mistaken for "there is nothing here".
+ */
+export function ErrorState({ what, onRetry, isRetrying }: ErrorStateProps) {
+ return (
+
+
+
+ {what} konnten nicht geladen werden. Meist hilft es, es gleich noch einmal zu versuchen.
+
+ {onRetry ? (
+
+
+ {isRetrying ? "Lädt…" : "Erneut versuchen"}
+
+ ) : null}
+
+ );
+}
+
+export function LoadingState({ what }: { what: string }) {
+ return (
+
+ {what} werden geladen…
+
+ );
+}
+
+interface QueryLike
{
+ data: T | undefined;
+ isLoading: boolean;
+ isError: boolean;
+ isFetching?: boolean;
+ refetch: () => unknown;
+}
+
+interface QueryStateProps {
+ query: QueryLike;
+ /** Plural noun for messages: "Völker", "Ernten". */
+ what: string;
+ /** Rendered when the data is an empty list. */
+ empty?: ReactNode;
+ children: (data: T) => ReactNode;
+}
+
+/**
+ * Loading, error (with retry), empty and data - always in that order, so
+ * a failed request can never look like an empty list.
+ */
+export function QueryState({ query, what, empty, children }: QueryStateProps) {
+ if (query.isLoading) {
+ return ;
+ }
+ if (query.isError || query.data === undefined) {
+ return (
+ void query.refetch()}
+ isRetrying={query.isFetching}
+ />
+ );
+ }
+ if (empty && Array.isArray(query.data) && query.data.length === 0) {
+ return <>{empty}>;
+ }
+ return <>{children(query.data)}>;
+}
diff --git a/ApiCore-Frontend/src/components/stat-bar-list.tsx b/ApiCore-Frontend/src/components/stat-bar-list.tsx
index 8362f23..703dae1 100644
--- a/ApiCore-Frontend/src/components/stat-bar-list.tsx
+++ b/ApiCore-Frontend/src/components/stat-bar-list.tsx
@@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
0 ? Math.max((item.value / max) * 100, 4) : 0}%` }}
/>
diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx
index 0ffcaee..422cea3 100644
--- a/ApiCore-Frontend/src/components/ui/button.tsx
+++ b/ApiCore-Frontend/src/components/ui/button.tsx
@@ -21,10 +21,11 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
+ // Phones get at least 40 px: tapped with a thumb, often with gloves.
default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ "h-10 sm:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ sm: "h-9 sm:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
// 44 px: reliable with gloves or sticky fingers at the apiary.
touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5",
diff --git a/ApiCore-Frontend/src/components/ui/dialog.tsx b/ApiCore-Frontend/src/components/ui/dialog.tsx
index f526bf5..78ec8c3 100644
--- a/ApiCore-Frontend/src/components/ui/dialog.tsx
+++ b/ApiCore-Frontend/src/components/ui/dialog.tsx
@@ -53,7 +53,7 @@ function DialogContent({
) {
type={type}
data-slot="input"
className={cn(
- "h-9 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+ "h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
diff --git a/ApiCore-Frontend/src/components/ui/select.tsx b/ApiCore-Frontend/src/components/ui/select.tsx
index 5724aa8..fe7448c 100644
--- a/ApiCore-Frontend/src/components/ui/select.tsx
+++ b/ApiCore-Frontend/src/components/ui/select.tsx
@@ -41,7 +41,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
- "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
diff --git a/ApiCore-Frontend/src/components/ui/sheet.tsx b/ApiCore-Frontend/src/components/ui/sheet.tsx
index 78c0a76..d5d1dfe 100644
--- a/ApiCore-Frontend/src/components/ui/sheet.tsx
+++ b/ApiCore-Frontend/src/components/ui/sheet.tsx
@@ -53,7 +53,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
- "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
+ "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}