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
+202 -103

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,20 +168,23 @@ 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>
{href ? (
<Link className="font-medium text-primary underline-offset-4 hover:underline" href={href}>
Ansehen
</Link>
) : null}
</div>
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{alert.message}</span>
{href ? (
<Link
className="shrink-0 font-medium text-primary underline-offset-4 hover:underline"
href={href}
>
Ansehen
</Link>
) : null}
</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}