design v5: Übersicht als Vorschau, Grundbausteine im neuen Look
Tiefblauer Grund mit Lichtflecken in den Bereichsfarben, Glasflächen mit Lichtkante (flaeche), ruhigere Schrift (Überschriften normal geschrieben, Großbuchstaben nur als kleines Etikett) und dezente Bewegung. - Startseite neu: Geräte-Ring mit Gesamtlage, drei Eckwerte, Instrumente (8 Messringe), Bereichskarten mit Kacheln je Modell, Baustein und Gerät (Symbol, Zustandspunkt, Update direkt an der Kachel), Speicher/NAS, Zeitleisten „Als Nächstes“ und „Zuletzt eingespielt“. - Panel, Schildchen, Kennzahl, Verlaufslinie, Seitenleiste und Kopf im neuen Look — wirkt schon auf allen Seiten. - Symbole je Gerät (lib/geraetebild): Farbe des Bereichs, Zustände nur als Punkt. Vorbilder: Heimdall, Dashy, Homepage, umbrelOS, Pulse. Noch nicht ausgerollt (kein dist): erst nach dem Ja des Users. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b816fe6d32
commit
948e55e4f2
@@ -3,18 +3,25 @@ import { cn } from "cn"
|
||||
|
||||
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
|
||||
|
||||
// v5: zarte Tönung mit feinem Innenrand und einem Punkt vor dem Wort — Zustand als Farbe, Punkt und Wort.
|
||||
const ART: Record<ChipArt, string> = {
|
||||
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
|
||||
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
rot: "border-rot-rand bg-rot-grund text-rot-text",
|
||||
grau: "border-linie bg-erhaben text-text-2",
|
||||
gruen: "bg-gruen/10 text-gruen ring-gruen/30",
|
||||
bernstein: "bg-bernstein/10 text-bernstein ring-bernstein/35",
|
||||
cyan: "bg-cyan/10 text-cyan-text ring-cyan/30",
|
||||
rot: "bg-rot/12 text-rot-text ring-rot/40",
|
||||
grau: "bg-white/[0.04] text-text-2 ring-white/12",
|
||||
}
|
||||
|
||||
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
|
||||
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
|
||||
<span
|
||||
className={cn(
|
||||
"etikett inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11px] tracking-[0.08em] whitespace-nowrap ring-1 ring-inset",
|
||||
ART[art],
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-current opacity-90" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
@@ -26,8 +33,8 @@ export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string
|
||||
<div
|
||||
role={fehler ? "alert" : "status"}
|
||||
className={cn(
|
||||
"rounded-2xl border px-5 py-8 text-center text-[15px]",
|
||||
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
|
||||
"rounded-2xl px-5 py-8 text-center text-[15px]",
|
||||
fehler ? "border border-rot-rand bg-rot-grund text-rot-text" : "flaeche text-text-2",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cn } from "cn"
|
||||
import { type Geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild"
|
||||
|
||||
// Kachel in der Farbe des Bereichs; Zustände trägt nur der Punkt (grün/bernstein/rot/cyan) — nie das Symbol.
|
||||
const BEREICH = {
|
||||
box: "from-box/30 to-box/[0.05] ring-box/35 text-box-text",
|
||||
homelab: "from-lab/30 to-lab/[0.05] ring-lab/35 text-lab-text",
|
||||
} as const
|
||||
|
||||
const LEUCHTEN = {
|
||||
box: "shadow-[0_0_28px_-6px_var(--box)]",
|
||||
homelab: "shadow-[0_0_28px_-6px_var(--lab)]",
|
||||
} as const
|
||||
|
||||
const GROESSE = {
|
||||
sm: { kachel: "size-8 rounded-[10px]", icon: "size-4", punkt: "size-2.5" },
|
||||
md: { kachel: "size-10 rounded-xl", icon: "size-5", punkt: "size-2.5" },
|
||||
lg: { kachel: "size-12 rounded-2xl", icon: "size-6", punkt: "size-3" },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Das Symbol eines Geräts oder Dienstes als kleine Kachel, optional mit Zustandspunkt unten rechts. Rein
|
||||
* schmückend (aria-hidden) — Name und Zustand stehen daneben als Text.
|
||||
*/
|
||||
export function Geraetesymbol({ bild, groesse = "md", punkt, leuchtet = false, className }: {
|
||||
bild: Geraetebild
|
||||
groesse?: keyof typeof GROESSE
|
||||
punkt?: Punktzustand | null
|
||||
/** Weicher Schein in der Bereichsfarbe — für die großen Kacheln im Kopf der Bereichskarten. */
|
||||
leuchtet?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const g = GROESSE[groesse]
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center justify-center bg-gradient-to-br ring-1 ring-inset",
|
||||
BEREICH[bild.bereich], g.kachel, leuchtet && LEUCHTEN[bild.bereich], className,
|
||||
)}
|
||||
>
|
||||
<bild.Icon className={g.icon} strokeWidth={1.75} />
|
||||
{punkt && (
|
||||
<span className={cn("absolute -right-0.5 -bottom-0.5 rounded-full ring-2 ring-[#0b0f16]", g.punkt, PUNKT[punkt])} />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,40 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
|
||||
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
|
||||
/**
|
||||
* Ein Feld des Instrumentenbretts: Glasfläche mit Lichtkante (Design v5), Überschrift in normaler Schreibung,
|
||||
* auf Wunsch mit Symbol davor.
|
||||
*/
|
||||
export function Panel({
|
||||
titel,
|
||||
rechts,
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
symbol: Symbol,
|
||||
}: {
|
||||
titel?: string
|
||||
rechts?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
id?: string
|
||||
symbol?: LucideIcon
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={titel}
|
||||
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
|
||||
className={cn("flaeche flex min-w-0 flex-col gap-4 rounded-2xl p-5 sm:p-6", className)}
|
||||
>
|
||||
{(titel || rechts) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
|
||||
{titel && (
|
||||
<h2 className="flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground">
|
||||
{Symbol && <Symbol className="size-[18px] text-text-3" aria-hidden />}
|
||||
{titel}
|
||||
</h2>
|
||||
)}
|
||||
{rechts}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,11 +26,11 @@ export function Kennzahl({ label, wert, einheit, punkte, max, zustand = "ruhig",
|
||||
}) {
|
||||
const z = ZUSTAND[zustand]
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 pt-3 pb-2">
|
||||
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
|
||||
<div className="flaeche-innen flex min-w-0 flex-col gap-1.5 rounded-xl px-3.5 pt-3 pb-2">
|
||||
<span className="etikett text-[11px] text-text-3">{label}</span>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className={cn("font-anzeige text-[28px] leading-none font-bold", z.wert)}>{wertText(wert, einheit)}</span>
|
||||
{z.marke && <span className={cn("schild text-[11px]", z.wert)}>{z.marke}</span>}
|
||||
<span className={cn("font-anzeige text-[28px] leading-none font-semibold tabular-nums", z.wert)}>{wertText(wert, einheit)}</span>
|
||||
{z.marke && <span className={cn("etikett text-[10.5px]", z.wert)}>{z.marke}</span>}
|
||||
</span>
|
||||
{punkte && punkte.length > 1 && (
|
||||
<Sparkline punkte={punkte} max={max} farbe={farbe} beschriftung={`${label}, ${zeitraumText}`} />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import { cn } from "cn"
|
||||
import type { Bereich } from "@/lib/navigation"
|
||||
import { type Einheit, wertText } from "@/lib/messung"
|
||||
import type { Messpunkt } from "@/lib/typen"
|
||||
import { Sparkline } from "./Sparkline"
|
||||
|
||||
// Ein Messwert als Ring (Design v5, 25.09.2026): der Bogen füllt sich bis zum Wert, im Normalfall in der Farbe des
|
||||
// Bereichs, ab der gelben Grenze bernstein, ab der roten rot — dazu das Wort („hoch“, „kritisch“), damit es nicht
|
||||
// nur an der Farbe hängt. Zwei feine Striche am Rand zeigen, wo die Grenzen liegen; darunter die Linie der Stunde.
|
||||
|
||||
const ZUSTAND = {
|
||||
ruhig: { wert: "text-foreground", marke: null },
|
||||
gelb: { wert: "text-bernstein", marke: "hoch" },
|
||||
rot: { wert: "text-rot-text", marke: "kritisch" },
|
||||
} as const
|
||||
|
||||
const MITTE = 48
|
||||
const RADIUS = 37
|
||||
const UMFANG = 2 * Math.PI * RADIUS
|
||||
const BOGEN = UMFANG * 0.75
|
||||
|
||||
/** Punkt auf dem Rand für einen Anteil 0…1 des 270°-Bogens (Beginn unten links, im Uhrzeigersinn). */
|
||||
function randpunkt(anteil: number, r: number) {
|
||||
const winkel = ((135 + 270 * anteil) * Math.PI) / 180
|
||||
return { x: MITTE + r * Math.cos(winkel), y: MITTE + r * Math.sin(winkel) }
|
||||
}
|
||||
|
||||
export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bereich, grenzen, max = 100, zeitraumText, i = 0 }: {
|
||||
label: string
|
||||
wert: number | null | undefined
|
||||
einheit: Einheit
|
||||
punkte?: Messpunkt[]
|
||||
zustand?: keyof typeof ZUSTAND
|
||||
bereich: Bereich
|
||||
/** Gelbe und rote Grenze in der Einheit des Werts. */
|
||||
grenzen: [number, number]
|
||||
max?: number
|
||||
zeitraumText: string
|
||||
/** Reihenfolge für das Auftauchen. */
|
||||
i?: number
|
||||
}) {
|
||||
const z = ZUSTAND[zustand]
|
||||
const farbe = zustand === "rot" ? "var(--rot)" : zustand === "gelb" ? "var(--bernstein)" : bereich === "box" ? "var(--box)" : "var(--lab)"
|
||||
const anteil = wert == null ? 0 : Math.min(Math.max(wert / max, 0), 1)
|
||||
const [zahl, einheitText] = wertText(wert, einheit).split(" ")
|
||||
return (
|
||||
<div className="flaeche-innen flex min-w-0 flex-col items-center gap-1 rounded-2xl px-2 pt-2.5 pb-2">
|
||||
<div className="relative size-[88px]">
|
||||
<svg viewBox="0 0 96 96" className="size-full" aria-hidden>
|
||||
<circle cx={MITTE} cy={MITTE} r={RADIUS} fill="none" stroke="rgb(255 255 255 / 0.07)" strokeWidth={7}
|
||||
strokeLinecap="round" strokeDasharray={`${BOGEN} ${UMFANG}`} transform={`rotate(135 ${MITTE} ${MITTE})`} />
|
||||
{grenzen.map((g, n) => {
|
||||
const a = randpunkt(Math.min(g / max, 1), RADIUS + 6)
|
||||
const b = randpunkt(Math.min(g / max, 1), RADIUS + 9.5)
|
||||
return <line key={g} x1={a.x} y1={a.y} x2={b.x} y2={b.y} strokeWidth={1.5} strokeLinecap="round"
|
||||
stroke={n === 0 ? "var(--bernstein)" : "var(--rot)"} opacity={0.55} />
|
||||
})}
|
||||
{wert != null && anteil > 0 && (
|
||||
<circle cx={MITTE} cy={MITTE} r={RADIUS} fill="none" stroke={farbe} strokeWidth={7} strokeLinecap="round"
|
||||
strokeDasharray={`${Math.max(BOGEN * anteil, 0.5)} ${UMFANG}`} transform={`rotate(135 ${MITTE} ${MITTE})`}
|
||||
className="ring-fuellt transition-[stroke-dasharray] duration-700"
|
||||
style={{ "--i": i, filter: `drop-shadow(0 0 5px color-mix(in srgb, ${farbe} 65%, transparent))` } as CSSProperties} />
|
||||
)}
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center pt-1">
|
||||
<span className={cn("font-anzeige text-[26px] leading-none font-semibold tabular-nums", z.wert)}>{zahl}</span>
|
||||
{einheitText && <span className="ml-0.5 self-center pt-1.5 text-[12px] font-medium text-text-3">{einheitText}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="etikett -mt-1 flex items-center gap-1.5 text-[10.5px] text-text-3">
|
||||
{label}
|
||||
{z.marke && <span className={z.wert}>· {z.marke}</span>}
|
||||
</span>
|
||||
<div className="w-full px-1">
|
||||
{punkte && punkte.length > 1
|
||||
? <Sparkline punkte={punkte} max={max} farbe={farbe} hoehe={20} beschriftung={`${label}, ${zeitraumText}`} />
|
||||
: <span className="block h-5" aria-hidden />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useId } from "react"
|
||||
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
|
||||
import type { Messpunkt } from "@/lib/typen"
|
||||
|
||||
/**
|
||||
* Kleine Verlaufslinie für Kennzahl-Kacheln: keine Achsen, nur der Verlauf des Zeitraums in der Reihenfarbe
|
||||
* mit zarter Fläche. Die Zahl daneben trägt den Wert; die Linie zeigt nur die Richtung.
|
||||
* mit zarter Fläche, die nach unten ausläuft (v5). Die Zahl daneben trägt den Wert; die Linie zeigt nur die Richtung.
|
||||
*/
|
||||
export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, beschriftung }: {
|
||||
punkte: Messpunkt[]
|
||||
@@ -19,6 +20,8 @@ export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, b
|
||||
const x = (t: number) => (t1 > t0 ? ((t - t0) / (t1 - t0)) * breite : 0)
|
||||
const y = (v: number) => hoehe - 2 - (Math.min(v, max) / (max || 1)) * (hoehe - 4)
|
||||
const hatWerte = punkte.some(([, v]) => v !== null)
|
||||
// Eigene ID je Linie für den Verlauf; Sonderzeichen aus useId stören in url(#…).
|
||||
const verlauf = `sl${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -30,7 +33,13 @@ export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, b
|
||||
>
|
||||
{hatWerte && (
|
||||
<>
|
||||
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={farbe} opacity={0.12} />
|
||||
<defs>
|
||||
<linearGradient id={verlauf} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={farbe} stopOpacity={0.3} />
|
||||
<stop offset="1" stopColor={farbe} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={`url(#${verlauf})`} />
|
||||
<path d={linienPfad(punkte, x, y)} fill="none" stroke={farbe} strokeWidth={2} strokeLinecap="round"
|
||||
strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CSSProperties } from "react"
|
||||
|
||||
// Der Geräte-Ring der Startseite (Design v5, 25.09.2026): ein Abschnitt je Gerät — KI-Box, Proxmox-Host und jeder
|
||||
// Gast —, grün, wenn es antwortet, rot, wenn nicht, grau, solange es keinen Stand gibt. In der Mitte die Zahl in
|
||||
// Worten. Außen kreist langsam eine feine Skala; die Glut dahinter trägt die Farbe der Gesamtlage.
|
||||
|
||||
export interface Segment {
|
||||
id: string
|
||||
name: string
|
||||
zustand: "ok" | "rot" | "aus"
|
||||
}
|
||||
|
||||
const FARBE: Record<Segment["zustand"], string> = {
|
||||
ok: "var(--gruen)",
|
||||
rot: "var(--rot)",
|
||||
aus: "rgb(135 146 164 / 0.45)",
|
||||
}
|
||||
|
||||
const ZUSTAND_TEXT: Record<Segment["zustand"], string> = { ok: "erreichbar", rot: "antwortet nicht", aus: "kein Stand" }
|
||||
|
||||
const M = 90
|
||||
const R = 72
|
||||
const STRICH = 9
|
||||
const UMFANG = 2 * Math.PI * R
|
||||
|
||||
export function Lagering({ segmente, glut }: { segmente: Segment[]; glut: string }) {
|
||||
const n = Math.max(segmente.length, 1)
|
||||
const teil = UMFANG / n
|
||||
// Runde Enden ragen je halbe Strichbreite über; die Lücke muss das mit abdecken.
|
||||
const luecke = n > 1 ? STRICH + 5 : 0
|
||||
const laenge = Math.max(teil - luecke, 0.5)
|
||||
const erreichbar = segmente.filter((s) => s.zustand === "ok").length
|
||||
return (
|
||||
<div className="relative size-[132px] shrink-0 sm:size-[168px]">
|
||||
<svg viewBox="0 0 180 180" className="size-full" aria-hidden
|
||||
style={{ filter: `drop-shadow(0 0 14px color-mix(in srgb, ${glut} 40%, transparent))` }}>
|
||||
<g className="kreist">
|
||||
<circle cx={M} cy={M} r={86} fill="none" stroke="rgb(255 255 255 / 0.13)" strokeWidth={1} strokeDasharray="1.2 5.6" />
|
||||
</g>
|
||||
<circle cx={M} cy={M} r={R} fill="none" stroke="rgb(255 255 255 / 0.05)" strokeWidth={STRICH} />
|
||||
<circle cx={M} cy={M} r={58} fill="none" stroke="rgb(255 255 255 / 0.06)" strokeWidth={1} />
|
||||
<g transform={`rotate(-90 ${M} ${M})`}>
|
||||
{segmente.map((s, i) => (
|
||||
<circle key={s.id} cx={M} cy={M} r={R} fill="none" stroke={FARBE[s.zustand]} strokeWidth={STRICH}
|
||||
strokeLinecap="round" strokeDasharray={`${laenge} ${UMFANG - laenge}`}
|
||||
strokeDashoffset={-(i * teil + luecke / 2)} className="ring-fuellt" style={{ "--i": i } as CSSProperties}>
|
||||
<title>{`${s.name}: ${ZUSTAND_TEXT[s.zustand]}`}</title>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-anzeige text-[38px] leading-none font-semibold tabular-nums sm:text-[46px]">
|
||||
{erreichbar}
|
||||
<span className="text-text-3">/{segmente.length}</span>
|
||||
</span>
|
||||
<span className="etikett mt-1.5 flex flex-col items-center text-[9.5px] leading-[1.35] text-text-3 sm:text-[10px]">
|
||||
<span>Geräte</span>
|
||||
<span>erreichbar</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user