style(frontend): grundlegendes Redesign (Design-System, Farben, Typografie)

Neues Design-System: warme Honig-/Wachs-Farbpalette (Amber-Primaer,
Salbeigruen-Sekundaer) statt Standard-Graustufen, Space Grotesk fuer
Ueberschriften, Inter fuer Fliesstext, IBM Plex Mono fuer Codes/IDs.
Card-Komponente mit weicher Schattierung statt Ring, neuer
Badge-Warning-Variante und statusTone()-Helper fuer semantische
Status-Farben (Voelker/Honigchargen). Navigation umgebaut: aktiver
Link-Zustand, Hexagon-Markenzeichen, "Mehr"-Dropdown fuer Admin-Links.
Dashboard-Kacheln mit Icons/Farbakzenten, Login-Seite mit dezentem
Waben-Hintergrundmuster als wiederkehrendes Signature-Element.
This commit is contained in:
Nick Szaunig committed 2026-08-22 10:45:41 +02:00
1 parent 26b2eb0829
commit e7348b0b68
38 files changed
+350 -201

No files matched your search

@@ -19,7 +19,7 @@ export default function ApiaryDetailPage() {
} }
if (isError || !apiary) { if (isError || !apiary) {
return ( return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6"> <p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Bienenstand konnte nicht geladen werden. Bienenstand konnte nicht geladen werden.
</p> </p>
); );
@@ -88,7 +88,7 @@ export default function NewApiaryPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -36,7 +36,7 @@ export default function ApiariesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Bienenstände</h1> <h1 className="text-2xl font-semibold tracking-tight">Bienenstände</h1>
<Button nativeButton={false} render={<Link href="/apiaries/new" />}> <Button nativeButton={false} render={<Link href="/apiaries/new" />}>
+ Neuer Bienenstand + Neuer Bienenstand
</Button> </Button>
@@ -66,7 +66,7 @@ export default function ApiariesPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Bienenstände konnten nicht geladen werden. Bienenstände konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -23,7 +23,7 @@ export default function ColonyDetailPage() {
} }
if (isError || !colony) { if (isError || !colony) {
return ( return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6"> <p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Volk konnte nicht geladen werden. Volk konnte nicht geladen werden.
</p> </p>
); );
@@ -114,7 +114,7 @@ export default function NewColonyPage() {
</Select> </Select>
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { ColonyStatus } from "@/lib/colonies/types"; import { ColonyStatus } from "@/lib/colonies/types";
import { exportColoniesCsv } from "@/lib/export/api"; import { exportColoniesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -48,7 +49,7 @@ export default function ColoniesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Völker</h1> <h1 className="text-2xl font-semibold tracking-tight">Völker</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -82,7 +83,7 @@ export default function ColoniesPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Völker konnten nicht geladen werden. Völker konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -100,15 +101,16 @@ export default function ColoniesPage() {
{colonies.map((colony) => ( {colonies.map((colony) => (
<TableRow key={colony.id}> <TableRow key={colony.id}>
<TableCell> <TableCell>
<Link className="underline" href={`/colonies/${colony.id}`}> <Link
className="font-mono font-medium text-primary underline-offset-4 hover:underline"
href={`/colonies/${colony.id}`}
>
{colony.code} {colony.code}
</Link> </Link>
</TableCell> </TableCell>
<TableCell>{colony.colonyType}</TableCell> <TableCell>{colony.colonyType}</TableCell>
<TableCell> <TableCell>
<Badge variant={colony.status === "ACTIVE" ? "default" : "secondary"}> <Badge variant={statusTone(colony.status)}>{colony.status}</Badge>
{colony.status}
</Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -2,10 +2,19 @@
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
Crown,
Droplet,
ListTodo,
type LucideIcon,
MapPin,
Layers,
} from "lucide-react";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { DashboardAlert } from "@/lib/dashboard/types"; import { DashboardAlert } from "@/lib/dashboard/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
function alertHref(alert: DashboardAlert): string | null { function alertHref(alert: DashboardAlert): string | null {
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`; if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
@@ -14,6 +23,25 @@ function alertHref(alert: DashboardAlert): string | null {
return null; return null;
} }
const ALERT_STYLES: Record<string, string> = {
OVERDUE_TASK: "border-destructive/30 bg-destructive/5",
CONSPICUOUS_COLONY: "border-warning/40 bg-warning/10",
LOW_STOCK: "border-warning/40 bg-warning/10",
};
interface StatTile {
label: string;
value: number;
icon: LucideIcon;
tone: "primary" | "secondary" | "warning";
}
const TONE_CLASSES: Record<StatTile["tone"], string> = {
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary/10 text-secondary",
warning: "bg-warning/15 text-warning-foreground",
};
export default function DashboardPage() { export default function DashboardPage() {
const { data: summary, isLoading: summaryLoading } = useQuery({ const { data: summary, isLoading: summaryLoading } = useQuery({
queryKey: ["dashboard", "summary"], queryKey: ["dashboard", "summary"],
@@ -24,58 +52,74 @@ export default function DashboardPage() {
queryFn: fetchDashboardAlerts, queryFn: fetchDashboardAlerts,
}); });
const tiles = summary const tiles: StatTile[] = summary
? [ ? [
{ label: "Aktive Bienenstände", value: summary.apiariesActive }, { label: "Aktive Bienenstände", value: summary.apiariesActive, icon: MapPin, tone: "primary" },
{ label: "Aktive Völker", value: summary.coloniesActive }, { label: "Aktive Völker", value: summary.coloniesActive, icon: Layers, tone: "primary" },
{ label: "Aktive Königinnen", value: summary.queensActive }, { label: "Aktive Königinnen", value: summary.queensActive, icon: Crown, tone: "secondary" },
{ label: "Offene Aufgaben", value: summary.openTasks }, { label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, tone: "secondary" },
{ label: "Honigchargen", value: summary.honeyBatchesTotal }, { label: "Honigchargen", value: summary.honeyBatchesTotal, icon: Droplet, tone: "primary" },
{ label: "Artikel unter Mindestbestand", value: summary.stockItemsLowCount }, {
label: "Artikel unter Mindestbestand",
value: summary.stockItemsLowCount,
icon: AlertTriangle,
tone: summary.stockItemsLowCount > 0 ? "warning" : "secondary",
},
] ]
: []; : [];
return ( return (
<main className="flex flex-col gap-6 p-4 sm:p-6"> <main className="flex flex-col gap-6 p-4 sm:p-6">
<h1 className="text-xl font-semibold">Dashboard</h1> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Überblick über deine Imkerei</p>
</div>
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{tiles.map((tile) => ( {tiles.map((tile) => {
<Card key={tile.label}> const Icon = tile.icon;
<CardHeader className="pb-1"> return (
<CardTitle className="text-sm font-normal text-muted-foreground"> <Card key={tile.label}>
{tile.label} <CardContent className="flex flex-col gap-3">
</CardTitle> <div
</CardHeader> className={`flex size-9 items-center justify-center rounded-lg ${TONE_CLASSES[tile.tone]}`}
<CardContent> >
<p className="text-2xl font-semibold">{tile.value}</p> <Icon className="size-4.5" />
</CardContent> </div>
</Card> <div>
))} <p className="text-2xl font-semibold tabular-nums">{tile.value}</p>
<p className="text-xs text-muted-foreground">{tile.label}</p>
</div>
</CardContent>
</Card>
);
})}
</div> </div>
<Card> <div>
<CardHeader> <h2 className="mb-3 text-lg font-semibold tracking-tight">Hinweise</h2>
<CardTitle>Hinweise</CardTitle> <div className="flex flex-col gap-2">
</CardHeader> {alertsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
<CardContent className="flex flex-col gap-2 text-sm">
{alertsLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!alertsLoading && (!alerts || alerts.length === 0) ? ( {!alertsLoading && (!alerts || alerts.length === 0) ? (
<p className="text-muted-foreground">Keine offenen Hinweise.</p> <Card>
<CardContent className="text-sm text-muted-foreground">
Keine offenen Hinweise.
</CardContent>
</Card>
) : null} ) : null}
{alerts?.map((alert) => { {alerts?.map((alert) => {
const href = alertHref(alert); const href = alertHref(alert);
return ( return (
<div <div
key={`${alert.type}-${alert.relatedId}`} key={`${alert.type}-${alert.relatedId}`}
className="flex items-center justify-between rounded-md border p-3" className={`flex items-center justify-between rounded-xl border p-3 text-sm ${ALERT_STYLES[alert.type] ?? "border-border bg-card"}`}
> >
<span>{alert.message}</span> <span>{alert.message}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary">{alert.type}</Badge> <Badge variant="secondary">{alert.type}</Badge>
{href ? ( {href ? (
<Link className="underline" href={href}> <Link className="font-medium text-primary underline-offset-4 hover:underline" href={href}>
Ansehen Ansehen
</Link> </Link>
) : null} ) : null}
@@ -83,8 +127,8 @@ export default function DashboardPage() {
</div> </div>
); );
})} })}
</CardContent> </div>
</Card> </div>
</main> </main>
); );
} }
@@ -151,7 +151,7 @@ export default function NewExtractionPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -183,7 +183,7 @@ export default function NewFeedingPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -19,7 +19,7 @@ export default function HarvestDetailPage() {
} }
if (isError || !harvest) { if (isError || !harvest) {
return ( return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6"> <p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Ernte konnte nicht geladen werden. Ernte konnte nicht geladen werden.
</p> </p>
); );
@@ -176,7 +176,7 @@ export default function NewHarvestPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -94,7 +94,7 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -29,7 +29,7 @@ export default function HoneyBatchDetailPage() {
} }
if (isError || !honeyBatch) { if (isError || !honeyBatch) {
return ( return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6"> <p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Honigcharge konnte nicht geladen werden. Honigcharge konnte nicht geladen werden.
</p> </p>
); );
@@ -122,7 +122,7 @@ export function CreateHoneyBatchDialog() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { HoneyBatchStatus } from "@/lib/honey-batches/types"; import { HoneyBatchStatus } from "@/lib/honey-batches/types";
import { exportHoneyBatchesCsv } from "@/lib/export/api"; import { exportHoneyBatchesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -49,7 +50,7 @@ export default function HoneyBatchesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Honigchargen</h1> <h1 className="text-2xl font-semibold tracking-tight">Honigchargen</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -83,7 +84,7 @@ export default function HoneyBatchesPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Honigchargen konnten nicht geladen werden. Honigchargen konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -102,7 +103,10 @@ export default function HoneyBatchesPage() {
{honeyBatches.map((honeyBatch) => ( {honeyBatches.map((honeyBatch) => (
<TableRow key={honeyBatch.id}> <TableRow key={honeyBatch.id}>
<TableCell> <TableCell>
<Link className="underline" href={`/honey-batches/${honeyBatch.id}`}> <Link
className="font-mono font-medium text-primary underline-offset-4 hover:underline"
href={`/honey-batches/${honeyBatch.id}`}
>
{honeyBatch.batchNumber} {honeyBatch.batchNumber}
</Link> </Link>
</TableCell> </TableCell>
@@ -111,7 +115,7 @@ export default function HoneyBatchesPage() {
{honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—"} {honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge>{honeyBatch.status}</Badge> <Badge variant={statusTone(honeyBatch.status)}>{honeyBatch.status}</Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -194,7 +194,7 @@ export default function NewInspectionPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
+66 -47
View File
@@ -2,14 +2,53 @@
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Hexagon, MoreHorizontal } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { BottomNav } from "./bottom-nav"; import { BottomNav } from "./bottom-nav";
import { QuickAddMenu } from "./quick-add-menu"; import { QuickAddMenu } from "./quick-add-menu";
const NAV_LINK_CLASS =
"shrink-0 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary";
const PRIMARY_LINKS = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Bienenstände", href: "/apiaries" },
{ label: "Völker", href: "/colonies" },
{ label: "Königinnen", href: "/queens" },
{ label: "Aufgaben", href: "/tasks" },
{ label: "Honigchargen", href: "/honey-batches" },
{ label: "Lager", href: "/storage" },
{ label: "Suche", href: "/search" },
];
const ADMIN_LINKS = [
{ label: "Benutzer", href: "/settings/users" },
{ label: "Audit-Log", href: "/settings/audit-log" },
];
function NavLink({ href, label, pathname }: { href: string; label: string; pathname: string }) {
return (
<Link
href={href}
aria-current={pathname.startsWith(href) ? "page" : undefined}
className={NAV_LINK_CLASS}
>
{label}
</Link>
);
}
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user, isLoading, logout } = useAuth(); const { user, isLoading, logout } = useAuth();
useEffect(() => { useEffect(() => {
@@ -24,53 +63,33 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<header className="flex items-center justify-between gap-3 border-b px-4 py-3"> <header className="flex items-center justify-between gap-3 border-b bg-card px-4 py-2.5">
<nav className="flex min-w-0 items-center gap-4 max-sm:gap-2"> <nav className="flex min-w-0 items-center gap-3 max-sm:gap-2">
<span className="shrink-0 font-semibold">ApiCore</span> <Link href="/dashboard" className="flex shrink-0 items-center gap-1.5 font-heading font-semibold">
<div className="hidden min-w-0 items-center gap-4 overflow-x-auto whitespace-nowrap sm:flex"> <Hexagon className="size-5 fill-primary/15 text-primary" strokeWidth={2.25} />
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/dashboard"> ApiCore
Dashboard
</Link> </Link>
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/apiaries"> <div className="hidden min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap sm:flex">
Bienenstände {PRIMARY_LINKS.map((item) => (
</Link> <NavLink key={item.href} {...item} pathname={pathname} />
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/colonies"> ))}
Völker {user.role === "ADMIN" ? (
</Link> <DropdownMenu>
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/queens"> <DropdownMenuTrigger
Königinnen render={<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" />}
</Link> >
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/tasks"> <MoreHorizontal className="size-4" />
Aufgaben Mehr
</Link> </DropdownMenuTrigger>
<Link <DropdownMenuContent align="start">
className="text-sm text-muted-foreground hover:text-foreground" {ADMIN_LINKS.map((item) => (
href="/honey-batches" <DropdownMenuItem key={item.href} render={<Link href={item.href} />}>
> {item.label}
Honigchargen </DropdownMenuItem>
</Link> ))}
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/storage"> </DropdownMenuContent>
Lager </DropdownMenu>
</Link> ) : null}
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/search">
Suche
</Link>
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
href="/settings/users"
>
Benutzer
</Link>
) : null}
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
href="/settings/audit-log"
>
Audit-Log
</Link>
) : null}
</div> </div>
</nav> </nav>
<div className="flex shrink-0 items-center gap-3 text-sm"> <div className="flex shrink-0 items-center gap-3 text-sm">
@@ -19,7 +19,7 @@ export default function QueenDetailPage() {
} }
if (isError || !queen) { if (isError || !queen) {
return ( return (
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6"> <p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Königin konnte nicht geladen werden. Königin konnte nicht geladen werden.
</p> </p>
); );
@@ -73,7 +73,7 @@ export default function NewQueenPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -45,7 +45,7 @@ export default function QueensPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Königinnen</h1> <h1 className="text-2xl font-semibold tracking-tight">Königinnen</h1>
<Button nativeButton={false} render={<Link href="/queens/new" />}> <Button nativeButton={false} render={<Link href="/queens/new" />}>
+ Neue Königin + Neue Königin
</Button> </Button>
@@ -70,7 +70,7 @@ export default function QueensPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Königinnen konnten nicht geladen werden. Königinnen konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -43,7 +43,7 @@ export default function SearchPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<h1 className="text-xl font-semibold">Suche</h1> <h1 className="text-2xl font-semibold tracking-tight">Suche</h1>
<Input <Input
className="h-11 max-w-lg" className="h-11 max-w-lg"
placeholder="Bienenstand, Volk, Königin, Aufgabe, Charge…" placeholder="Bienenstand, Volk, Königin, Aufgabe, Charge…"
@@ -30,7 +30,7 @@ export default function AuditLogPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<h1 className="text-xl font-semibold">Audit-Log</h1> <h1 className="text-2xl font-semibold tracking-tight">Audit-Log</h1>
<form <form
className="flex flex-wrap items-end gap-3" className="flex flex-wrap items-end gap-3"
@@ -70,7 +70,7 @@ export default function AuditLogPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Audit-Log konnte nicht geladen werden. Audit-Log konnte nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -110,7 +110,7 @@ export function CreateUserDialog() {
</Select> </Select>
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -31,13 +31,13 @@ export default function UsersPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Benutzerverwaltung</h1> <h1 className="text-2xl font-semibold tracking-tight">Benutzerverwaltung</h1>
<CreateUserDialog /> <CreateUserDialog />
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Benutzer konnten nicht geladen werden. Benutzer konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -115,7 +115,7 @@ export function CreateStockItemDialog() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -95,7 +95,7 @@ export function CreateStorageContainerDialog() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -29,7 +29,7 @@ export default function StoragePage() {
<main className="flex flex-col gap-8 p-4 sm:p-6"> <main className="flex flex-col gap-8 p-4 sm:p-6">
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Lagerbehälter</h1> <h1 className="text-2xl font-semibold tracking-tight">Lagerbehälter</h1>
<CreateStorageContainerDialog /> <CreateStorageContainerDialog />
</div> </div>
{containersLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {containersLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -63,7 +63,7 @@ export default function StoragePage() {
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Lagerartikel</h2> <h2 className="text-2xl font-semibold tracking-tight">Lagerartikel</h2>
<CreateStockItemDialog /> <CreateStockItemDialog />
</div> </div>
{itemsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {itemsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -94,7 +94,7 @@ export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -104,7 +104,7 @@ export default function NewTaskPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -57,7 +57,7 @@ export default function TasksPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Aufgaben</h1> <h1 className="text-2xl font-semibold tracking-tight">Aufgaben</h1>
<Button nativeButton={false} render={<Link href="/tasks/new" />}> <Button nativeButton={false} render={<Link href="/tasks/new" />}>
+ Neue Aufgabe + Neue Aufgabe
</Button> </Button>
@@ -110,7 +110,7 @@ export default function TasksPage() {
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? ( {isError ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
Aufgaben konnten nicht geladen werden. Aufgaben konnten nicht geladen werden.
</p> </p>
) : null} ) : null}
@@ -153,7 +153,7 @@ export default function NewTreatmentPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
@@ -173,7 +173,7 @@ export default function NewVarroaMeasurementPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
+37 -5
View File
@@ -2,6 +2,7 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -14,6 +15,33 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
function HoneycombBackdrop() {
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full text-primary/[0.07]"
>
<defs>
<pattern
id="honeycomb"
width="56"
height="97"
patternUnits="userSpaceOnUse"
patternTransform="scale(1)"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.5"
d="M28 0 56 16.2 56 48.5 28 64.7 0 48.5 0 16.2Z M28 64.7 56 80.8 56 97 28 97 0 97 0 80.8Z"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#honeycomb)" />
</svg>
);
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { login } = useAuth(); const { login } = useAuth();
@@ -37,11 +65,15 @@ export default function LoginPage() {
} }
return ( return (
<main className="flex min-h-screen items-center justify-center bg-zinc-50 p-4 dark:bg-black"> <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<Card className="w-full max-w-sm"> <HoneycombBackdrop />
<Card className="relative w-full max-w-sm">
<CardHeader> <CardHeader>
<CardTitle>ApiCore Login</CardTitle> <div className="mb-1 flex items-center gap-2">
<CardDescription>Digitale Imkereiverwaltung</CardDescription> <Hexagon className="size-6 fill-primary/15 text-primary" strokeWidth={2.25} />
<CardTitle className="text-lg">ApiCore</CardTitle>
</div>
<CardDescription>Digitale Imkereiverwaltung für Wabenhain</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
@@ -68,7 +100,7 @@ export default function LoginPage() {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-destructive">
{error} {error}
</p> </p>
) : null} ) : null}
+85 -66
View File
@@ -7,9 +7,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-body);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-data);
--font-heading: var(--font-sans); --font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -27,6 +27,10 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
@@ -49,72 +53,80 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.98 0.012 85);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.24 0.025 55);
--card: oklch(1 0 0); --card: oklch(0.995 0.006 85);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.24 0.025 55);
--popover: oklch(1 0 0); --popover: oklch(0.995 0.006 85);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.24 0.025 55);
--primary: oklch(0.205 0 0); --primary: oklch(0.6 0.135 50);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.98 0.02 85);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.46 0.06 145);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.98 0.015 100);
--muted: oklch(0.97 0 0); --muted: oklch(0.93 0.016 85);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.5 0.03 65);
--accent: oklch(0.97 0 0); --accent: oklch(0.94 0.028 75);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.28 0.04 50);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.5 0.16 30);
--border: oklch(0.922 0 0); --border: oklch(0.88 0.02 78);
--input: oklch(0.922 0 0); --input: oklch(0.88 0.02 78);
--ring: oklch(0.708 0 0); --ring: oklch(0.6 0.135 50);
--chart-1: oklch(0.87 0 0); --warning: oklch(0.68 0.14 75);
--chart-2: oklch(0.556 0 0); --warning-foreground: oklch(0.24 0.04 60);
--chart-3: oklch(0.439 0 0); --success: oklch(0.46 0.06 145);
--chart-4: oklch(0.371 0 0); --success-foreground: oklch(0.98 0.015 100);
--chart-5: oklch(0.269 0 0); --chart-1: oklch(0.6 0.135 50);
--radius: 0.625rem; --chart-2: oklch(0.46 0.06 145);
--sidebar: oklch(0.985 0 0); --chart-3: oklch(0.68 0.14 75);
--sidebar-foreground: oklch(0.145 0 0); --chart-4: oklch(0.5 0.16 30);
--sidebar-primary: oklch(0.205 0 0); --chart-5: oklch(0.55 0.05 250);
--sidebar-primary-foreground: oklch(0.985 0 0); --radius: 0.75rem;
--sidebar-accent: oklch(0.97 0 0); --sidebar: oklch(0.98 0.012 85);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-foreground: oklch(0.24 0.025 55);
--sidebar-border: oklch(0.922 0 0); --sidebar-primary: oklch(0.6 0.135 50);
--sidebar-ring: oklch(0.708 0 0); --sidebar-primary-foreground: oklch(0.98 0.02 85);
--sidebar-accent: oklch(0.94 0.028 75);
--sidebar-accent-foreground: oklch(0.28 0.04 50);
--sidebar-border: oklch(0.88 0.02 78);
--sidebar-ring: oklch(0.6 0.135 50);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.2 0.018 55);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.94 0.02 80);
--card: oklch(0.205 0 0); --card: oklch(0.25 0.02 55);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.94 0.02 80);
--popover: oklch(0.205 0 0); --popover: oklch(0.25 0.02 55);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.94 0.02 80);
--primary: oklch(0.922 0 0); --primary: oklch(0.72 0.13 55);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.2 0.02 55);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.62 0.075 145);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.15 0.02 145);
--muted: oklch(0.269 0 0); --muted: oklch(0.29 0.018 55);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.7 0.025 75);
--accent: oklch(0.269 0 0); --accent: oklch(0.32 0.03 60);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.94 0.02 80);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.62 0.16 30);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 16%);
--ring: oklch(0.556 0 0); --ring: oklch(0.72 0.13 55);
--chart-1: oklch(0.87 0 0); --warning: oklch(0.74 0.13 75);
--chart-2: oklch(0.556 0 0); --warning-foreground: oklch(0.2 0.03 60);
--chart-3: oklch(0.439 0 0); --success: oklch(0.62 0.075 145);
--chart-4: oklch(0.371 0 0); --success-foreground: oklch(0.15 0.02 145);
--chart-5: oklch(0.269 0 0); --chart-1: oklch(0.72 0.13 55);
--sidebar: oklch(0.205 0 0); --chart-2: oklch(0.62 0.075 145);
--sidebar-foreground: oklch(0.985 0 0); --chart-3: oklch(0.74 0.13 75);
--sidebar-primary: oklch(0.488 0.243 264.376); --chart-4: oklch(0.62 0.16 30);
--sidebar-primary-foreground: oklch(0.985 0 0); --chart-5: oklch(0.65 0.06 250);
--sidebar-accent: oklch(0.269 0 0); --sidebar: oklch(0.22 0.02 55);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.94 0.02 80);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-primary: oklch(0.72 0.13 55);
--sidebar-ring: oklch(0.556 0 0); --sidebar-primary-foreground: oklch(0.2 0.02 55);
--sidebar-accent: oklch(0.32 0.03 60);
--sidebar-accent-foreground: oklch(0.94 0.02 80);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.72 0.13 55);
} }
@layer base { @layer base {
@@ -127,4 +139,11 @@
html { html {
@apply font-sans; @apply font-sans;
} }
h1,
h2,
h3,
h4 {
font-family: var(--font-heading);
letter-spacing: -0.01em;
}
} }
+13 -6
View File
@@ -1,16 +1,23 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "./providers"; import { Providers } from "./providers";
const geistSans = Geist({ const spaceGrotesk = Space_Grotesk({
variable: "--font-geist-sans", variable: "--font-heading",
subsets: ["latin"],
weight: ["500", "600", "700"],
});
const inter = Inter({
variable: "--font-body",
subsets: ["latin"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ const plexMono = IBM_Plex_Mono({
variable: "--font-geist-mono", variable: "--font-data",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -22,7 +29,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${spaceGrotesk.variable} ${inter.variable} ${plexMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers> <Providers>{children}</Providers>
@@ -14,6 +14,8 @@ const badgeVariants = cva(
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
warning:
"bg-warning/15 text-warning-foreground focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
+1 -1
View File
@@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border/60 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[0_1px_2px_-1px_rgba(33,20,10,0.06),0_2px_8px_-2px_rgba(33,20,10,0.06)] transition-shadow [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className className
)} )}
{...props} {...props}
+20
View File
@@ -0,0 +1,20 @@
export type StatusTone = "default" | "secondary" | "warning" | "destructive" | "outline";
const WARNING_STATUSES = new Set([
"WEAK",
"CONSPICUOUS",
"QUEENLESS",
"LOW_STOCK",
]);
const DESTRUCTIVE_STATUSES = new Set(["DISSOLVED", "DECEASED", "SOLD"]);
const NEUTRAL_STATUSES = new Set(["MERGED", "CONSUMED"]);
export function statusTone(status: string): StatusTone {
if (WARNING_STATUSES.has(status)) return "warning";
if (DESTRUCTIVE_STATUSES.has(status)) return "destructive";
if (NEUTRAL_STATUSES.has(status)) return "outline";
if (status === "ACTIVE") return "default";
return "secondary";
}