feat(frontend): moderneres visuelles Redesign (Farben, Tiefe, Dashboard-Hero)

Kartenhintergrund und Seitenhintergrund waren fast farbgleich, wodurch
alles flach wirkte trotz vorhandenem Schatten. Staerkerer Kontrast
(--background/--card/--muted/--border), neue Gradient- und
Schatten-Tokens (--gradient-honey/-sage/-sky, --shadow-soft/-elevated).

Card-Komponente: rounded-2xl, echter sichtbarer Schatten, optionaler
Hover-Lift (interactive-Prop). Sidebar: solide Gradient-Badge, kraeftiger
Active-Pill-Zustand statt blassem Tint. Header: sticky mit Blur-Schatten
statt hartem Rand. Dashboard: Gradient-Hero mit Begruessung und
Honeycomb-Muster, farbige Gradient-Icon-Badges pro KPI-Kachel,
Hinweise-Zeilen mit Icon statt rohem Enum-Badge (dabei toten
CONSPICUOUS_COLONY-Style-Key gefunden und auf COLONY_ATTENTION
korrigiert). Honeycomb-Hintergrund aus der Login-Seite in eine
gemeinsame Komponente extrahiert und dort mit dem gleichen
Gradient-Badge-Header konsistent gemacht.
This commit is contained in:
Nick Szaunig committed 2026-08-23 20:10:33 +02:00
1 parent 6ab028d1be
commit 5cacc3c60d
7 files changed
+198 -99

No files matched your search

@@ -10,11 +10,14 @@ import {
type LucideIcon,
MapPin,
Layers,
PackageSearch,
ShieldAlert,
} from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { DashboardAlert } from "@/lib/dashboard/types";
import { Badge } from "@/components/ui/badge";
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
import { Card, CardContent } from "@/components/ui/card";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
function alertHref(alert: DashboardAlert): string | null {
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
@@ -23,26 +26,43 @@ function alertHref(alert: DashboardAlert): string | 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",
};
function greeting(): string {
const hour = new Date().getHours();
if (hour < 11) return "Guten Morgen";
if (hour < 18) return "Guten Tag";
return "Guten Abend";
}
interface StatTile {
label: string;
value: number;
icon: LucideIcon;
tone: "primary" | "secondary" | "warning";
gradient: string;
}
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",
const ALERT_META: Record<
DashboardAlertType,
{ label: string; icon: LucideIcon; className: string }
> = {
OVERDUE_TASK: {
label: "Aufgabe überfällig",
icon: ListTodo,
className: "border-destructive/30 bg-destructive/5",
},
COLONY_ATTENTION: {
label: "Volk auffällig",
icon: ShieldAlert,
className: "border-warning/40 bg-warning/10",
},
LOW_STOCK: {
label: "Bestand niedrig",
icon: PackageSearch,
className: "border-warning/40 bg-warning/10",
},
};
export default function DashboardPage() {
const { user } = useAuth();
const { data: summary, isLoading: summaryLoading } = useQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
@@ -54,25 +74,62 @@ export default function DashboardPage() {
const tiles: StatTile[] = summary
? [
{ label: "Aktive Bienenstände", value: summary.apiariesActive, icon: MapPin, tone: "primary" },
{ label: "Aktive Völker", value: summary.coloniesActive, icon: Layers, tone: "primary" },
{ label: "Aktive Königinnen", value: summary.queensActive, icon: Crown, tone: "secondary" },
{ label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, tone: "secondary" },
{ label: "Honigchargen", value: summary.honeyBatchesTotal, icon: Droplet, tone: "primary" },
{
label: "Aktive Bienenstände",
value: summary.apiariesActive,
icon: MapPin,
gradient: "var(--gradient-honey)",
},
{
label: "Aktive Völker",
value: summary.coloniesActive,
icon: Layers,
gradient: "var(--gradient-honey)",
},
{
label: "Aktive Königinnen",
value: summary.queensActive,
icon: Crown,
gradient: "var(--gradient-sage)",
},
{
label: "Offene Aufgaben",
value: summary.openTasks,
icon: ListTodo,
gradient: "var(--gradient-sky)",
},
{
label: "Honigchargen",
value: summary.honeyBatchesTotal,
icon: Droplet,
gradient: "var(--gradient-honey)",
},
{
label: "Artikel unter Mindestbestand",
value: summary.stockItemsLowCount,
icon: AlertTriangle,
tone: summary.stockItemsLowCount > 0 ? "warning" : "secondary",
gradient:
summary.stockItemsLowCount > 0
? "linear-gradient(135deg, oklch(0.72 0.15 70) 0%, oklch(0.56 0.15 50) 100%)"
: "var(--gradient-sage)",
},
]
: [];
return (
<main className="flex flex-col gap-6 p-4 sm:p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Überblick über deine Imkerei</p>
<div
className="relative overflow-hidden rounded-2xl px-5 py-6 text-white shadow-[var(--shadow-elevated)] sm:px-8 sm:py-8"
style={{ background: "var(--gradient-honey)" }}
>
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
<div className="relative flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting()}
{user ? `, ${user.email.split("@")[0]}` : ""}
</h1>
<p className="text-sm text-white/85 sm:text-base">Überblick über deine Imkerei</p>
</div>
</div>
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -80,12 +137,13 @@ export default function DashboardPage() {
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<Card key={tile.label}>
<Card key={tile.label} interactive>
<CardContent className="flex flex-col gap-3">
<div
className={`flex size-9 items-center justify-center rounded-lg ${TONE_CLASSES[tile.tone]}`}
className="flex size-10 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: tile.gradient }}
>
<Icon className="size-4.5" />
<Icon className="size-5" />
</div>
<div>
<p className="text-2xl font-semibold tabular-nums">{tile.value}</p>
@@ -98,7 +156,7 @@ export default function DashboardPage() {
</div>
<div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Hinweise</h2>
<h2 className="mb-3 font-heading text-lg font-semibold tracking-tight">Hinweise</h2>
<div className="flex flex-col gap-2">
{alertsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{!alertsLoading && (!alerts || alerts.length === 0) ? (
@@ -110,21 +168,24 @@ export default function DashboardPage() {
) : null}
{alerts?.map((alert) => {
const href = alertHref(alert);
const meta = ALERT_META[alert.type];
const AlertIcon = meta?.icon ?? AlertTriangle;
return (
<div
key={`${alert.type}-${alert.relatedId}`}
className={`flex items-center justify-between rounded-xl border p-3 text-sm ${ALERT_STYLES[alert.type] ?? "border-border bg-card"}`}
className={`flex items-center gap-3 rounded-xl border p-3 text-sm shadow-[var(--shadow-soft)] ${meta?.className ?? "border-border bg-card"}`}
>
<span>{alert.message}</span>
<div className="flex items-center gap-2">
<Badge variant="secondary">{alert.type}</Badge>
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{alert.message}</span>
{href ? (
<Link className="font-medium text-primary underline-offset-4 hover:underline" href={href}>
<Link
className="shrink-0 font-medium text-primary underline-offset-4 hover:underline"
href={href}
>
Ansehen
</Link>
) : null}
</div>
</div>
);
})}
</div>
+7 -2
View File
@@ -30,12 +30,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div className="flex min-h-screen">
<Sidebar user={user} />
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center justify-between gap-3 border-b bg-card px-4 py-2.5 sm:justify-end sm:px-6">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center gap-1.5 font-heading font-semibold sm:hidden"
>
<Hexagon className="size-5 fill-primary/15 text-primary" strokeWidth={2.25} />
<span
className="flex size-7 items-center justify-center rounded-lg"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4 fill-white/25 text-white" strokeWidth={2.25} />
</span>
ApiCore
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
+19 -9
View File
@@ -93,13 +93,18 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-150",
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-150",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
)}
>
<Icon className="size-4.5 shrink-0" />
<Icon
className={cn(
"size-4.5 shrink-0 transition-transform duration-150",
!isActive && "group-hover:scale-110",
)}
/>
<span className="truncate">{item.label}</span>
</Link>
);
@@ -110,19 +115,24 @@ export function Sidebar({ user }: { user: AuthUser }) {
const groups = user.role === "ADMIN" ? [...NAV_GROUPS, ADMIN_GROUP] : NAV_GROUPS;
return (
<aside className="hidden w-60 shrink-0 flex-col gap-1 overflow-y-auto border-r bg-card px-3 py-4 sm:flex">
<aside className="relative z-10 hidden w-64 shrink-0 flex-col gap-1 overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
<Link
href="/dashboard"
className="mb-4 flex items-center gap-2 px-2 font-heading text-lg font-semibold"
className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold"
>
<Hexagon className="size-5.5 fill-primary/15 text-primary" strokeWidth={2.25} />
<span
className="flex size-8 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4.5 fill-white/25 text-white" strokeWidth={2.25} />
</span>
ApiCore
</Link>
<nav className="flex flex-1 flex-col gap-4">
<nav className="flex flex-1 flex-col gap-5">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col gap-0.5">
{group.label ? (
<p className="px-3 pb-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
{group.label}
</p>
) : null}
+10 -31
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/lib/auth/auth-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import {
Card,
CardContent,
@@ -15,33 +16,6 @@ import {
CardTitle,
} 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() {
const router = useRouter();
const { login } = useAuth();
@@ -66,11 +40,16 @@ export default function LoginPage() {
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<HoneycombBackdrop />
<Card className="relative w-full max-w-sm">
<HoneycombPattern />
<Card className="relative w-full max-w-sm shadow-[var(--shadow-elevated)]">
<CardHeader>
<div className="mb-1 flex items-center gap-2">
<Hexagon className="size-6 fill-primary/15 text-primary" strokeWidth={2.25} />
<div className="mb-1 flex items-center gap-2.5">
<span
className="flex size-9 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-5 fill-white/25 text-white" strokeWidth={2.25} />
</span>
<CardTitle className="text-lg">ApiCore</CardTitle>
</div>
<CardDescription>Digitale Imkereiverwaltung für Wabenhain</CardDescription>
+34 -24
View File
@@ -65,24 +65,24 @@
}
:root {
--background: oklch(0.98 0.012 85);
--foreground: oklch(0.24 0.025 55);
--card: oklch(0.995 0.006 85);
--card-foreground: oklch(0.24 0.025 55);
--popover: oklch(0.995 0.006 85);
--popover-foreground: oklch(0.24 0.025 55);
--primary: oklch(0.6 0.135 50);
--primary-foreground: oklch(0.98 0.02 85);
--secondary: oklch(0.46 0.06 145);
--background: oklch(0.955 0.017 80);
--foreground: oklch(0.22 0.028 55);
--card: oklch(0.995 0.004 90);
--card-foreground: oklch(0.22 0.028 55);
--popover: oklch(0.995 0.004 90);
--popover-foreground: oklch(0.22 0.028 55);
--primary: oklch(0.6 0.15 48);
--primary-foreground: oklch(0.99 0.02 85);
--secondary: oklch(0.44 0.07 145);
--secondary-foreground: oklch(0.98 0.015 100);
--muted: oklch(0.93 0.016 85);
--muted-foreground: oklch(0.5 0.03 65);
--accent: oklch(0.94 0.028 75);
--accent-foreground: oklch(0.28 0.04 50);
--muted: oklch(0.9 0.02 82);
--muted-foreground: oklch(0.48 0.032 65);
--accent: oklch(0.93 0.032 74);
--accent-foreground: oklch(0.26 0.045 50);
--destructive: oklch(0.5 0.16 30);
--border: oklch(0.88 0.02 78);
--input: oklch(0.88 0.02 78);
--ring: oklch(0.6 0.135 50);
--border: oklch(0.85 0.024 78);
--input: oklch(0.85 0.024 78);
--ring: oklch(0.6 0.15 48);
--warning: oklch(0.68 0.14 75);
--warning-foreground: oklch(0.24 0.04 60);
--success: oklch(0.46 0.06 145);
@@ -93,14 +93,19 @@
--chart-4: oklch(0.5 0.16 30);
--chart-5: oklch(0.55 0.05 250);
--radius: 0.75rem;
--sidebar: oklch(0.98 0.012 85);
--sidebar-foreground: oklch(0.24 0.025 55);
--sidebar-primary: oklch(0.6 0.135 50);
--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);
--sidebar: oklch(0.995 0.004 90);
--sidebar-foreground: oklch(0.22 0.028 55);
--sidebar-primary: oklch(0.6 0.15 48);
--sidebar-primary-foreground: oklch(0.99 0.02 85);
--sidebar-accent: oklch(0.93 0.032 74);
--sidebar-accent-foreground: oklch(0.26 0.045 50);
--sidebar-border: oklch(0.85 0.024 78);
--sidebar-ring: oklch(0.6 0.15 48);
--gradient-honey: linear-gradient(135deg, oklch(0.74 0.15 62) 0%, oklch(0.56 0.16 38) 100%);
--gradient-sage: linear-gradient(135deg, oklch(0.64 0.09 150) 0%, oklch(0.4 0.075 145) 100%);
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
}
.dark {
@@ -139,6 +144,11 @@
--sidebar-accent-foreground: oklch(0.94 0.02 80);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.72 0.13 55);
--gradient-honey: linear-gradient(135deg, oklch(0.62 0.14 55) 0%, oklch(0.38 0.13 40) 100%);
--gradient-sage: linear-gradient(135deg, oklch(0.5 0.08 148) 0%, oklch(0.3 0.06 145) 100%);
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
}
@layer base {
@@ -0,0 +1,31 @@
interface HoneycombPatternProps {
className?: string;
id?: string;
}
export function HoneycombPattern({ className, id = "honeycomb" }: HoneycombPatternProps) {
return (
<svg
aria-hidden="true"
className={className ?? "pointer-events-none absolute inset-0 h-full w-full text-primary/[0.07]"}
>
<defs>
<pattern
id={id}
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(#${id})`} />
</svg>
);
}
+5 -2
View File
@@ -5,14 +5,17 @@ import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
interactive = false,
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; interactive?: boolean }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"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",
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--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-2xl *:[img:last-child]:rounded-b-2xl",
interactive &&
"motion-safe:hover:-translate-y-0.5 hover:shadow-[var(--shadow-elevated)] hover:border-border",
className
)}
{...props}