redesign: THERMAL warm brutalist dashboard UI
Complete visual redesign of all dashboard components with a warm brutalist command terminal aesthetic. Features editorial section numbering, IBM Plex typography, sharp zero-radius cards with colored accent strips, film grain overlay, and data-glow effects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f6a42c2dd2
commit
e94a7706ab
12 changed files with 641 additions and 548 deletions
|
|
@ -1,14 +1,14 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de" class="dark">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Daily Briefing</title>
|
<title>Daily Briefing</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-950 text-slate-100 antialiased">
|
<body class="antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Clock as ClockIcon } from "lucide-react";
|
|
||||||
|
|
||||||
/** Live clock with German-locale date. Updates every second. */
|
|
||||||
export default function Clock() {
|
export default function Clock() {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
|
@ -23,23 +21,24 @@ export default function Clock() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="hidden sm:flex items-center justify-center w-10 h-10 rounded-xl bg-white/5 border border-white/[0.06]">
|
<div className="flex flex-col items-end">
|
||||||
<ClockIcon className="w-5 h-5 text-slate-400" />
|
<div className="flex items-baseline gap-0">
|
||||||
</div>
|
<span className="text-2xl sm:text-3xl font-mono font-bold text-base-900 tracking-tighter data-glow-gold">
|
||||||
|
{hours}
|
||||||
<div className="flex flex-col items-end sm:items-start">
|
|
||||||
{/* Time display */}
|
|
||||||
<div className="flex items-baseline gap-0.5" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
|
||||||
<span className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
|
||||||
{hours}:{minutes}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm sm:text-base font-medium text-slate-400">
|
<span className="text-2xl sm:text-3xl font-mono font-bold text-gold/60 tracking-tighter animate-glow-pulse">
|
||||||
:{seconds}
|
:
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-mono font-bold text-base-900 tracking-tighter data-glow-gold">
|
||||||
|
{minutes}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-mono font-medium text-base-500 ml-0.5">
|
||||||
|
{seconds}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] font-mono text-base-500 tracking-wider uppercase mt-0.5">
|
||||||
{/* Date display */}
|
{dateStr}
|
||||||
<p className="text-xs text-slate-500 mt-0.5">{dateStr}</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff, Wifi } from "lucide-react";
|
import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff } from "lucide-react";
|
||||||
import type { HAData } from "../api";
|
import type { HAData } from "../api";
|
||||||
|
|
||||||
interface HomeAssistantProps {
|
interface HomeAssistantProps {
|
||||||
|
|
@ -10,12 +10,12 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in">
|
<div className="deck-card p-5 animate-fade-in">
|
||||||
<div className="flex items-center gap-3 text-red-400">
|
<div className="flex items-center gap-3 text-cherry">
|
||||||
<WifiOff className="w-5 h-5" />
|
<WifiOff className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Home Assistant nicht erreichbar</p>
|
<p className="text-sm font-medium">Home Assistant nicht erreichbar</p>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
|
<p className="text-xs text-base-600 mt-0.5">Verbindung fehlgeschlagen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,74 +23,59 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-violet-500/[0.04] to-transparent">
|
<div className="deck-card p-5 animate-fade-in" data-accent="iris">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
<Home className="w-4 h-4 text-iris" />
|
||||||
<Home className="w-4 h-4 text-violet-400" />
|
<h3 className="text-sm font-semibold text-base-900">Home Assistant</h3>
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-white">Home Assistant</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
|
||||||
<div
|
<span className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}>
|
||||||
className={`w-2 h-2 rounded-full ${data.online ? "bg-emerald-400" : "bg-red-400"}`}
|
|
||||||
/>
|
|
||||||
{data.online && (
|
|
||||||
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] font-medium ${data.online ? "text-emerald-400" : "text-red-400"}`}>
|
|
||||||
{data.online ? "Online" : "Offline"}
|
{data.online ? "Online" : "Offline"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Lights Section */}
|
{/* Lights */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Lightbulb className="w-3.5 h-3.5 text-violet-400/70" />
|
<Lightbulb className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">Lichter</span>
|
||||||
Lichter
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="badge bg-violet-500/15 text-violet-300">
|
<span className="tag border-iris/30 text-iris bg-iris/5">
|
||||||
{data.lights_on}/{data.lights_total} an
|
{data.lights_on}/{data.lights_total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.lights.length > 0 ? (
|
{data.lights.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-px bg-base-300">
|
||||||
{data.lights.map((light) => {
|
{data.lights.map((light) => {
|
||||||
const isOn = light.state === "on";
|
const isOn = light.state === "on";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={light.entity_id}
|
key={light.entity_id}
|
||||||
className={`
|
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-colors ${
|
||||||
flex flex-col items-center gap-1.5 px-2 py-2.5 rounded-xl border transition-colors
|
isOn ? "bg-gold/[0.08]" : "bg-base-50"
|
||||||
${
|
}`}
|
||||||
isOn
|
|
||||||
? "bg-amber-500/10 border-amber-500/20"
|
|
||||||
: "bg-white/[0.02] border-white/[0.04]"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-colors ${
|
className={`w-2 h-2 transition-all ${
|
||||||
isOn
|
isOn
|
||||||
? "bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
|
? "bg-gold shadow-[0_0_8px_#e8a44a80]"
|
||||||
: "bg-slate-700"
|
: "bg-base-400"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-center text-slate-400 leading-tight truncate w-full">
|
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
|
||||||
{light.name}
|
{light.name}
|
||||||
</span>
|
</span>
|
||||||
{isOn && light.brightness > 0 && (
|
{isOn && light.brightness > 0 && (
|
||||||
<span className="text-[9px] text-amber-400/70">
|
<span className="text-[9px] font-mono text-gold/70">
|
||||||
{Math.round((light.brightness / 255) * 100)}%
|
{Math.round((light.brightness / 255) * 100)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -99,49 +84,47 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-slate-600">Keine Lichter konfiguriert</p>
|
<p className="text-xs text-base-500">Keine Lichter konfiguriert</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Covers Section */}
|
{/* Covers */}
|
||||||
{data.covers.length > 0 && (
|
{data.covers.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-1.5 mb-3">
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
<ArrowUp className="w-3.5 h-3.5 text-violet-400/70" />
|
<ArrowUp className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">Rollos</span>
|
||||||
Rollos
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-px">
|
||||||
{data.covers.map((cover) => {
|
{data.covers.map((cover) => {
|
||||||
const isOpen = cover.state === "open";
|
const isOpen = cover.state === "open";
|
||||||
const isClosed = cover.state === "closed";
|
const isClosed = cover.state === "closed";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cover.entity_id}
|
key={cover.entity_id}
|
||||||
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
|
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<ArrowUp className="w-3.5 h-3.5 text-emerald-400" />
|
<ArrowUp className="w-3 h-3 text-mint" />
|
||||||
) : isClosed ? (
|
) : isClosed ? (
|
||||||
<ArrowDown className="w-3.5 h-3.5 text-slate-500" />
|
<ArrowDown className="w-3 h-3 text-base-500" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDown className="w-3.5 h-3.5 text-amber-400" />
|
<ArrowDown className="w-3 h-3 text-gold" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-slate-300">{cover.name}</span>
|
<span className="text-xs text-base-700">{cover.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{cover.position > 0 && (
|
{cover.position > 0 && (
|
||||||
<span className="text-[10px] text-slate-500" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
<span className="text-[10px] font-mono text-base-500">
|
||||||
{cover.position}%
|
{cover.position}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium ${
|
className={`text-[10px] font-mono font-medium ${
|
||||||
isOpen ? "text-emerald-400" : isClosed ? "text-slate-500" : "text-amber-400"
|
isOpen ? "text-mint" : isClosed ? "text-base-500" : "text-gold"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
|
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
|
||||||
|
|
@ -154,31 +137,26 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Temperature Sensors Section */}
|
{/* Temperature Sensors */}
|
||||||
{data.sensors.length > 0 && (
|
{data.sensors.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-1.5 mb-3">
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
<Thermometer className="w-3.5 h-3.5 text-violet-400/70" />
|
<Thermometer className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">Temperaturen</span>
|
||||||
Temperaturen
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-px">
|
||||||
{data.sensors.map((sensor) => (
|
{data.sensors.map((sensor) => (
|
||||||
<div
|
<div
|
||||||
key={sensor.entity_id}
|
key={sensor.entity_id}
|
||||||
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
|
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-slate-300">{sensor.name}</span>
|
<span className="text-xs text-base-700">{sensor.name}</span>
|
||||||
<span
|
<span className="text-sm data-value text-base-900">
|
||||||
className="text-sm font-semibold text-white"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
{typeof sensor.state === "number"
|
{typeof sensor.state === "number"
|
||||||
? sensor.state.toFixed(1)
|
? sensor.state.toFixed(1)
|
||||||
: sensor.state}
|
: sensor.state}
|
||||||
<span className="text-xs text-slate-500 ml-0.5">{sensor.unit}</span>
|
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) {
|
||||||
if (!slots || slots.length === 0) return null;
|
if (!slots || slots.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-4 animate-fade-in">
|
<div className="deck-card p-5 animate-fade-in h-full" data-accent="gold">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
Stundenverlauf
|
<span className="section-number">Prognose</span>
|
||||||
</h3>
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Horizontal scroll container - hidden scrollbar via globals.css */}
|
|
||||||
<div
|
<div
|
||||||
className="flex gap-2 overflow-x-auto pb-1"
|
className="flex gap-0 overflow-x-auto"
|
||||||
style={{ scrollbarWidth: "none" }}
|
style={{ scrollbarWidth: "none" }}
|
||||||
>
|
>
|
||||||
{slots.map((slot, i) => {
|
{slots.map((slot, i) => {
|
||||||
|
|
@ -27,49 +27,37 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) {
|
||||||
<div
|
<div
|
||||||
key={slot.time}
|
key={slot.time}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center gap-1.5 min-w-[4.25rem] px-2.5 py-3 rounded-xl
|
flex flex-col items-center gap-1.5 min-w-[4.5rem] px-3 py-3
|
||||||
border transition-colors duration-200
|
border-r border-base-300 last:border-r-0 transition-colors
|
||||||
${
|
${isNow ? "bg-gold/[0.06]" : "hover:bg-base-100"}
|
||||||
isNow
|
|
||||||
? "bg-white/[0.06] border-cyan-500/20"
|
|
||||||
: "bg-white/[0.02] border-white/[0.04] hover:bg-white/[0.04]"
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Time label */}
|
<span className={`text-[10px] font-mono font-medium tracking-wider uppercase ${
|
||||||
<span
|
isNow ? "text-gold" : "text-base-500"
|
||||||
className={`text-[11px] font-medium ${isNow ? "text-cyan-400" : "text-slate-500"}`}
|
}`}>
|
||||||
>
|
|
||||||
{isNow ? "Jetzt" : hour}
|
{isNow ? "Jetzt" : hour}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Weather icon */}
|
|
||||||
<span className="text-xl select-none" role="img" aria-label="weather">
|
<span className="text-xl select-none" role="img" aria-label="weather">
|
||||||
{slot.icon}
|
{slot.icon}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Temperature */}
|
<span className={`text-sm data-value ${isNow ? "text-gold data-glow-gold" : "text-base-900"}`}>
|
||||||
<span
|
{Math.round(slot.temp)}°
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
{Math.round(slot.temp)}°
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Precipitation bar */}
|
|
||||||
{slot.precip_chance > 0 && (
|
{slot.precip_chance > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<Droplets className="w-2.5 h-2.5 text-blue-400/60" />
|
<Droplets className="w-2.5 h-2.5 text-azure/60" />
|
||||||
<span className="text-[10px] text-blue-400/80">
|
<span className="text-[9px] font-mono text-azure/80">
|
||||||
{slot.precip_chance}%
|
{slot.precip_chance}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Precip bar visual */}
|
<div className="w-full bar-track mt-0.5">
|
||||||
<div className="w-full progress-bar mt-0.5">
|
|
||||||
<div
|
<div
|
||||||
className="progress-fill bg-blue-500/50"
|
className="bar-fill bg-azure/50"
|
||||||
style={{ width: `${Math.min(slot.precip_chance, 100)}%` }}
|
style={{ width: `${Math.min(slot.precip_chance, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,7 +69,6 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extracts "HH:mm" from an ISO time string or "HH:00" format. */
|
|
||||||
function formatHour(time: string): string {
|
function formatHour(time: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(time);
|
const d = new Date(time);
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,26 @@ export default function MqttCard({ data }: Props) {
|
||||||
const shown = expanded ? filtered : filtered.slice(0, 8);
|
const shown = expanded ? filtered : filtered.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5">
|
<div className="deck-card p-5 animate-fade-in" data-accent="iris">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Radio className="w-4 h-4 text-violet-400" />
|
<Radio className="w-4 h-4 text-iris" />
|
||||||
<h3 className="text-sm font-semibold text-white">MQTT</h3>
|
<h3 className="text-sm font-semibold text-base-900">MQTT</h3>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">
|
<span className="tag border-iris/30 text-iris bg-iris/5">
|
||||||
{data.entities.length} Entit{data.entities.length === 1 ? "ät" : "äten"}
|
{data.entities.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{data.connected ? (
|
{data.connected ? (
|
||||||
<Zap className="w-3.5 h-3.5 text-violet-400" />
|
<Zap className="w-3.5 h-3.5 text-iris" />
|
||||||
) : (
|
) : (
|
||||||
<ZapOff className="w-3.5 h-3.5 text-slate-600" />
|
<ZapOff className="w-3.5 h-3.5 text-base-500" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium ${
|
className={`text-[10px] font-mono font-medium ${
|
||||||
data.connected ? "text-violet-400" : "text-slate-600"
|
data.connected ? "text-mint" : "text-cherry"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{data.connected ? "Verbunden" : "Getrennt"}
|
{data.connected ? "Verbunden" : "Getrennt"}
|
||||||
|
|
@ -54,14 +55,10 @@ export default function MqttCard({ data }: Props) {
|
||||||
|
|
||||||
{/* Category filter tabs */}
|
{/* Category filter tabs */}
|
||||||
{categories.length > 1 && (
|
{categories.length > 1 && (
|
||||||
<div className="flex gap-1.5 mb-3 flex-wrap">
|
<div className="flex gap-1 mb-4 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter(null)}
|
onClick={() => setFilter(null)}
|
||||||
className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
|
className={`tab-btn ${filter === null ? "active" : ""}`}
|
||||||
filter === null
|
|
||||||
? "bg-violet-500/20 border-violet-500/30 text-violet-300"
|
|
||||||
: "bg-white/5 border-white/[0.06] text-slate-500 hover:text-slate-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -69,14 +66,16 @@ export default function MqttCard({ data }: Props) {
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setFilter(filter === cat ? null : cat)}
|
onClick={() => setFilter(filter === cat ? null : cat)}
|
||||||
className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
|
className={`tab-btn ${filter === cat ? "active" : ""} flex items-center gap-1.5`}
|
||||||
filter === cat
|
|
||||||
? "bg-violet-500/20 border-violet-500/30 text-violet-300"
|
|
||||||
: "bg-white/5 border-white/[0.06] text-slate-500 hover:text-slate-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
<span className="ml-1 opacity-50">{grouped[cat].length}</span>
|
<span
|
||||||
|
className={`text-[10px] font-bold ${
|
||||||
|
filter === cat ? "text-gold" : "text-base-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{grouped[cat].length}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,16 +83,16 @@ export default function MqttCard({ data }: Props) {
|
||||||
|
|
||||||
{/* Entity list */}
|
{/* Entity list */}
|
||||||
{data.entities.length === 0 ? (
|
{data.entities.length === 0 ? (
|
||||||
<div className="text-center py-6">
|
<div className="flex flex-col items-center justify-center py-8 text-base-500">
|
||||||
<Radio className="w-8 h-8 text-slate-700 mx-auto mb-2" />
|
<Radio className="w-8 h-8 mb-2 opacity-20" />
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs font-mono">
|
||||||
{data.connected
|
{data.connected
|
||||||
? "Warte auf MQTT-Nachrichten..."
|
? "Warte auf Nachrichten..."
|
||||||
: "MQTT nicht konfiguriert"}
|
: "Nicht konfiguriert"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-px max-h-80 overflow-y-auto">
|
||||||
{shown.map((entity) => (
|
{shown.map((entity) => (
|
||||||
<EntityRow key={entity.topic} entity={entity} />
|
<EntityRow key={entity.topic} entity={entity} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -104,7 +103,7 @@ export default function MqttCard({ data }: Props) {
|
||||||
{filtered.length > 8 && (
|
{filtered.length > 8 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="mt-3 w-full flex items-center justify-center gap-1 text-[10px] text-slate-500 hover:text-slate-300 transition-colors"
|
className="mt-3 w-full flex items-center justify-center gap-1.5 text-[10px] font-mono text-base-500 hover:text-base-700 transition-colors py-1.5"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -124,7 +123,7 @@ export default function MqttCard({ data }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntityRow({ entity }: { entity: MqttEntity }) {
|
function EntityRow({ entity }: { entity: MqttEntity }) {
|
||||||
const age = Math.round((Date.now() / 1000 - entity.timestamp));
|
const age = Math.round(Date.now() / 1000 - entity.timestamp);
|
||||||
const ageStr =
|
const ageStr =
|
||||||
age < 60
|
age < 60
|
||||||
? `${age}s`
|
? `${age}s`
|
||||||
|
|
@ -132,32 +131,41 @@ function EntityRow({ entity }: { entity: MqttEntity }) {
|
||||||
? `${Math.floor(age / 60)}m`
|
? `${Math.floor(age / 60)}m`
|
||||||
: `${Math.floor(age / 3600)}h`;
|
: `${Math.floor(age / 3600)}h`;
|
||||||
|
|
||||||
// Format value for display
|
|
||||||
const displayValue = formatValue(entity.value);
|
const displayValue = formatValue(entity.value);
|
||||||
const isNumeric = typeof entity.value === "number";
|
const isNumeric = typeof entity.value === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 px-2.5 py-1.5 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
|
<div className="flex items-center justify-between gap-3 px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors group">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[11px] text-slate-300 truncate">{entity.name}</p>
|
<p className="text-xs text-base-700 truncate">{entity.name}</p>
|
||||||
<p className="text-[9px] text-slate-600 truncate font-mono">{entity.topic}</p>
|
<p className="text-[9px] text-base-500 truncate font-mono mt-0.5">
|
||||||
|
{entity.topic}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-mono font-medium ${
|
className={`text-sm font-mono font-medium ${
|
||||||
isNumeric ? "text-violet-300" : "text-slate-300"
|
isNumeric ? "text-iris data-value" : "text-base-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-slate-700 w-6 text-right">{ageStr}</span>
|
{(entity as any).unit && (
|
||||||
|
<span className="text-[10px] font-mono text-base-500">
|
||||||
|
{(entity as any).unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[9px] font-mono text-base-500 w-6 text-right opacity-50">
|
||||||
|
{ageStr}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: any): string {
|
function formatValue(value: any): string {
|
||||||
if (value === null || value === undefined) return "—";
|
if (value === null || value === undefined) return "\u2014";
|
||||||
if (typeof value === "boolean") return value ? "ON" : "OFF";
|
if (typeof value === "boolean") return value ? "ON" : "OFF";
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return Number.isInteger(value) ? value.toString() : value.toFixed(1);
|
return Number.isInteger(value) ? value.toString() : value.toFixed(1);
|
||||||
|
|
@ -166,10 +174,9 @@ function formatValue(value: any): string {
|
||||||
return JSON.stringify(value).slice(0, 40);
|
return JSON.stringify(value).slice(0, 40);
|
||||||
}
|
}
|
||||||
const str = String(value);
|
const str = String(value);
|
||||||
// Common HA/MQTT states → nicer display
|
|
||||||
if (str === "on" || str === "ON") return "ON";
|
if (str === "on" || str === "ON") return "ON";
|
||||||
if (str === "off" || str === "OFF") return "OFF";
|
if (str === "off" || str === "OFF") return "OFF";
|
||||||
if (str === "online") return "Online";
|
if (str === "online") return "Online";
|
||||||
if (str === "offline") return "Offline";
|
if (str === "offline") return "Offline";
|
||||||
return str.length > 30 ? str.slice(0, 30) + "…" : str;
|
return str.length > 30 ? str.slice(0, 30) + "\u2026" : str;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import type { NewsResponse, NewsArticle } from "../api";
|
import type { NewsResponse, NewsArticle } from "../api";
|
||||||
|
|
||||||
interface NewsGridProps {
|
interface NewsGridProps {
|
||||||
|
|
@ -14,17 +14,16 @@ const CATEGORIES = [
|
||||||
{ key: "allgemein", label: "Allgemein" },
|
{ key: "allgemein", label: "Allgemein" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Map source names to badge colours. */
|
|
||||||
const SOURCE_COLORS: Record<string, string> = {
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
"heise": "bg-orange-500/20 text-orange-300",
|
heise: "border-gold/40 text-gold bg-gold/5",
|
||||||
"golem": "bg-blue-500/20 text-blue-300",
|
golem: "border-azure/40 text-azure bg-azure/5",
|
||||||
"spiegel": "bg-red-500/20 text-red-300",
|
spiegel: "border-cherry/40 text-cherry bg-cherry/5",
|
||||||
"tagesschau": "bg-sky-500/20 text-sky-300",
|
tagesschau: "border-azure/40 text-azure bg-azure/5",
|
||||||
"zeit": "bg-slate-500/20 text-slate-300",
|
zeit: "border-base-400 text-base-700 bg-base-200",
|
||||||
"faz": "bg-emerald-500/20 text-emerald-300",
|
faz: "border-mint/40 text-mint bg-mint/5",
|
||||||
"welt": "bg-indigo-500/20 text-indigo-300",
|
welt: "border-iris/40 text-iris bg-iris/5",
|
||||||
"t3n": "bg-purple-500/20 text-purple-300",
|
t3n: "border-iris/40 text-iris bg-iris/5",
|
||||||
"default": "bg-amber-500/15 text-amber-300",
|
default: "border-gold/30 text-gold-muted bg-gold/5",
|
||||||
};
|
};
|
||||||
|
|
||||||
function sourceColor(source: string): string {
|
function sourceColor(source: string): string {
|
||||||
|
|
@ -35,7 +34,6 @@ function sourceColor(source: string): string {
|
||||||
return SOURCE_COLORS.default;
|
return SOURCE_COLORS.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return a German relative time string like "vor 2 Stunden". */
|
|
||||||
function relativeTime(isoDate: string): string {
|
function relativeTime(isoDate: string): string {
|
||||||
try {
|
try {
|
||||||
const date = new Date(isoDate);
|
const date = new Date(isoDate);
|
||||||
|
|
@ -72,35 +70,34 @@ export default function NewsGrid({ data }: NewsGridProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{/* Header + category tabs */}
|
{/* Section header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
<div className="section-label">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-400">
|
<span className="section-number">04</span>
|
||||||
Nachrichten
|
<h2 className="section-title">Nachrichten</h2>
|
||||||
<span className="ml-2 text-xs font-normal text-slate-600">
|
<span className="section-rule" />
|
||||||
{filteredArticles.length}
|
<span className="text-xs font-mono text-base-500">{filteredArticles.length}</span>
|
||||||
</span>
|
</div>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
{/* Category tabs */}
|
||||||
{CATEGORIES.map((cat) => (
|
<div className="flex gap-1 flex-wrap mb-5">
|
||||||
<button
|
{CATEGORIES.map((cat) => (
|
||||||
key={cat.key}
|
<button
|
||||||
onClick={() => setActiveCategory(cat.key)}
|
key={cat.key}
|
||||||
className={`category-tab ${activeCategory === cat.key ? "active" : ""}`}
|
onClick={() => setActiveCategory(cat.key)}
|
||||||
>
|
className={`tab-btn ${activeCategory === cat.key ? "active" : ""}`}
|
||||||
{cat.label}
|
>
|
||||||
</button>
|
{cat.label}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Articles grid */}
|
{/* Articles grid */}
|
||||||
{filteredArticles.length === 0 ? (
|
{filteredArticles.length === 0 ? (
|
||||||
<div className="glass-card p-8 text-center text-slate-500 text-sm">
|
<div className="deck-card p-8 text-center text-base-500 text-sm">
|
||||||
Keine Artikel in dieser Kategorie.
|
Keine Artikel in dieser Kategorie.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
{filteredArticles.map((article) => (
|
{filteredArticles.map((article) => (
|
||||||
<ArticleCard key={article.id} article={article} />
|
<ArticleCard key={article.id} article={article} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -116,26 +113,26 @@ function ArticleCard({ article }: { article: NewsArticle }) {
|
||||||
href={article.url}
|
href={article.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="glass-card-hover group block p-4 cursor-pointer"
|
className="group block bg-base-50 p-4 hover:bg-base-100 transition-colors relative"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className={`badge ${sourceColor(article.source)}`}>
|
<span className={`tag ${sourceColor(article.source)}`}>
|
||||||
{article.source}
|
{article.source}
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink className="w-3 h-3 text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<ArrowUpRight className="w-3 h-3 text-base-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-slate-200 leading-snug line-clamp-2 group-hover:text-white transition-colors">
|
<h3 className="text-sm font-medium text-base-800 leading-snug line-clamp-2 group-hover:text-base-900 transition-colors">
|
||||||
{article.title}
|
{article.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-base-200">
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider">
|
<span className="text-[9px] font-mono text-base-500 uppercase tracking-wider">
|
||||||
{article.category}
|
{article.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-slate-600 ml-auto">
|
<span className="text-[9px] font-mono text-base-500 ml-auto">
|
||||||
{relativeTime(article.published_at)}
|
{relativeTime(article.published_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,33 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Server, Cpu, HardDrive, Wifi, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react";
|
import { Server, Cpu, HardDrive, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react";
|
||||||
import type { ServerStats } from "../api";
|
import type { ServerStats } from "../api";
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
server: ServerStats;
|
server: ServerStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return Tailwind text colour class based on percentage thresholds. */
|
|
||||||
function usageColor(pct: number): string {
|
function usageColor(pct: number): string {
|
||||||
if (pct >= 80) return "text-red-400";
|
if (pct >= 80) return "text-cherry";
|
||||||
if (pct >= 60) return "text-amber-400";
|
if (pct >= 60) return "text-gold";
|
||||||
return "text-emerald-400";
|
return "text-mint";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return SVG stroke colour (hex) based on percentage thresholds. */
|
|
||||||
function usageStroke(pct: number): string {
|
function usageStroke(pct: number): string {
|
||||||
if (pct >= 80) return "#f87171"; // red-400
|
if (pct >= 80) return "#e85a5a";
|
||||||
if (pct >= 60) return "#fbbf24"; // amber-400
|
if (pct >= 60) return "#e8a44a";
|
||||||
return "#34d399"; // emerald-400
|
return "#4ae8a8";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return track background colour based on percentage thresholds. */
|
|
||||||
function usageBarBg(pct: number): string {
|
function usageBarBg(pct: number): string {
|
||||||
if (pct >= 80) return "bg-red-500/70";
|
if (pct >= 80) return "bg-cherry";
|
||||||
if (pct >= 60) return "bg-amber-500/70";
|
if (pct >= 60) return "bg-gold";
|
||||||
return "bg-emerald-500/70";
|
return "bg-mint";
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageGlow(pct: number): string {
|
||||||
|
if (pct >= 80) return "data-glow-cherry";
|
||||||
|
if (pct >= 60) return "data-glow-gold";
|
||||||
|
return "data-glow-mint";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerCard({ server }: ServerCardProps) {
|
export default function ServerCard({ server }: ServerCardProps) {
|
||||||
|
|
@ -36,90 +39,88 @@ export default function ServerCard({ server }: ServerCardProps) {
|
||||||
const ramPct = Math.round(server.ram.pct);
|
const ramPct = Math.round(server.ram.pct);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-emerald-500/[0.04] to-transparent">
|
<div className="deck-card p-5 animate-fade-in" data-accent="mint">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
<Server className="w-4 h-4 text-mint" />
|
||||||
<Server className="w-4 h-4 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-white">{server.name}</h3>
|
<h3 className="text-sm font-semibold text-base-900">{server.name}</h3>
|
||||||
<p className="text-[10px] text-slate-500">{server.host}</p>
|
<p className="text-[10px] font-mono text-base-500">{server.host}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusDot online={server.online} />
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`status-dot ${server.online ? "bg-mint" : "bg-cherry"}`} />
|
||||||
|
<span className={`text-[10px] font-mono font-medium ${server.online ? "text-mint" : "text-cherry"}`}>
|
||||||
|
{server.online ? "Online" : "Offline"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!server.online ? (
|
{!server.online ? (
|
||||||
<div className="flex items-center justify-center py-8 text-slate-500 text-sm gap-2">
|
<div className="flex items-center justify-center py-8 text-base-500 text-sm gap-2">
|
||||||
<WifiOff className="w-4 h-4" />
|
<WifiOff className="w-4 h-4" />
|
||||||
<span>Offline</span>
|
<span>Offline</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* CPU Section */}
|
{/* CPU */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<CpuRing pct={cpuPct} size={64} strokeWidth={5} />
|
<CpuRing pct={cpuPct} size={60} strokeWidth={4} />
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Cpu className="w-3.5 h-3.5 text-slate-500" />
|
<Cpu className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">CPU</span>
|
||||||
CPU
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className={`text-xl font-bold ${usageColor(cpuPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
<span className={`text-xl data-value ${usageColor(cpuPct)} ${usageGlow(cpuPct)}`}>
|
||||||
{cpuPct}%
|
{cpuPct}%
|
||||||
</span>
|
</span>
|
||||||
{server.cpu.temp_c !== null && (
|
{server.cpu.temp_c !== null && (
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-[10px] font-mono text-base-500">
|
||||||
{Math.round(server.cpu.temp_c)}°C
|
{Math.round(server.cpu.temp_c)}°C
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-600 mt-0.5">
|
<p className="text-[10px] font-mono text-base-500 mt-0.5">
|
||||||
{server.cpu.cores} Kerne
|
{server.cpu.cores} Kerne
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RAM Section */}
|
{/* RAM */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<HardDrive className="w-3.5 h-3.5 text-slate-500" />
|
<HardDrive className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">RAM</span>
|
||||||
RAM
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold ${usageColor(ramPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
<span className={`text-xs data-value ${usageColor(ramPct)}`}>
|
||||||
{ramPct}%
|
{ramPct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-bar">
|
<div className="bar-track">
|
||||||
<div
|
<div
|
||||||
className={`progress-fill ${usageBarBg(ramPct)}`}
|
className={`bar-fill ${usageBarBg(ramPct)}`}
|
||||||
style={{ width: `${ramPct}%` }}
|
style={{ width: `${ramPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-slate-600 mt-1.5">
|
<p className="text-[10px] font-mono text-base-500 mt-1.5">
|
||||||
{server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB
|
{server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Uptime */}
|
{/* Uptime */}
|
||||||
{server.uptime && (
|
{server.uptime && (
|
||||||
<p className="text-[10px] text-slate-600">
|
<p className="text-[10px] font-mono text-base-500">
|
||||||
Uptime: <span className="text-slate-400">{server.uptime}</span>
|
Uptime: <span className="text-base-700">{server.uptime}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Docker Section */}
|
{/* Docker */}
|
||||||
{server.docker && (
|
{server.docker && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -127,34 +128,32 @@ export default function ServerCard({ server }: ServerCardProps) {
|
||||||
className="flex items-center justify-between w-full group"
|
className="flex items-center justify-between w-full group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Box className="w-3.5 h-3.5 text-slate-500" />
|
<Box className="w-3 h-3 text-base-500" />
|
||||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<span className="data-label">Docker</span>
|
||||||
Docker
|
<span className="tag border-mint/30 text-mint bg-mint/5 ml-1">
|
||||||
</span>
|
|
||||||
<span className="badge bg-emerald-500/15 text-emerald-300 ml-1">
|
|
||||||
{server.docker.running}
|
{server.docker.running}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{dockerExpanded ? (
|
{dockerExpanded ? (
|
||||||
<ChevronUp className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
<ChevronUp className="w-3 h-3 text-base-500 group-hover:text-base-700 transition-colors" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
<ChevronDown className="w-3 h-3 text-base-500 group-hover:text-base-700 transition-colors" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dockerExpanded && server.docker.containers.length > 0 && (
|
{dockerExpanded && server.docker.containers.length > 0 && (
|
||||||
<div className="mt-2.5 space-y-1 max-h-48 overflow-y-auto">
|
<div className="mt-2.5 space-y-px max-h-48 overflow-y-auto">
|
||||||
{server.docker.containers.map((c) => (
|
{server.docker.containers.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.name}
|
key={c.name}
|
||||||
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white/[0.02] border border-white/[0.04]"
|
className="flex items-center justify-between px-3 py-1.5 bg-base-100 border-l-2 border-base-300 hover:border-mint transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-slate-300 truncate mr-2">{c.name}</span>
|
<span className="text-xs text-base-700 truncate mr-2 font-mono">{c.name}</span>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium ${
|
className={`text-[10px] font-mono font-medium ${
|
||||||
c.status.toLowerCase().includes("up")
|
c.status.toLowerCase().includes("up")
|
||||||
? "text-emerald-400"
|
? "text-mint"
|
||||||
: "text-red-400"
|
: "text-cherry"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c.status.toLowerCase().includes("up") ? "Running" : c.status}
|
{c.status.toLowerCase().includes("up") ? "Running" : c.status}
|
||||||
|
|
@ -171,37 +170,7 @@ export default function ServerCard({ server }: ServerCardProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Green/Red online indicator dot. */
|
function CpuRing({ pct, size, strokeWidth }: { pct: number; size: number; strokeWidth: number }) {
|
||||||
function StatusDot({ online }: { online: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className={`w-2 h-2 rounded-full ${
|
|
||||||
online ? "bg-emerald-400" : "bg-red-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{online && (
|
|
||||||
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] font-medium ${online ? "text-emerald-400" : "text-red-400"}`}>
|
|
||||||
{online ? "Online" : "Offline"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SVG circular progress ring for CPU usage. */
|
|
||||||
function CpuRing({
|
|
||||||
pct,
|
|
||||||
size,
|
|
||||||
strokeWidth,
|
|
||||||
}: {
|
|
||||||
pct: number;
|
|
||||||
size: number;
|
|
||||||
strokeWidth: number;
|
|
||||||
}) {
|
|
||||||
const radius = (size - strokeWidth) / 2;
|
const radius = (size - strokeWidth) / 2;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
const offset = circumference - (pct / 100) * circumference;
|
const offset = circumference - (pct / 100) * circumference;
|
||||||
|
|
@ -209,16 +178,14 @@ function CpuRing({
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-shrink-0" style={{ width: size, height: size }}>
|
<div className="relative flex-shrink-0" style={{ width: size, height: size }}>
|
||||||
<svg width={size} height={size} className="-rotate-90">
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
{/* Track */}
|
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(148,163,184,0.1)"
|
stroke="#232326"
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
{/* Progress */}
|
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
|
|
@ -226,15 +193,14 @@ function CpuRing({
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={usageStroke(pct)}
|
stroke={usageStroke(pct)}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="butt"
|
||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
strokeDashoffset={offset}
|
strokeDashoffset={offset}
|
||||||
style={{ transition: "stroke-dashoffset 0.7s ease-out, stroke 0.3s ease" }}
|
style={{ transition: "stroke-dashoffset 0.7s ease-out, stroke 0.3s ease" }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/* Center icon */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<Cpu className={`w-4 h-4 ${usageColor(pct)} opacity-50`} />
|
<Cpu className={`w-3.5 h-3.5 ${usageColor(pct)} opacity-40`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,13 @@ const TABS: { key: TabKey; label: string }[] = [
|
||||||
{ key: "sams", label: "Sam's" },
|
{ key: "sams", label: "Sam's" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Colour for task priority (1 = highest). */
|
|
||||||
function priorityIndicator(priority: number): { color: string; label: string } {
|
function priorityIndicator(priority: number): { color: string; label: string } {
|
||||||
if (priority <= 1) return { color: "bg-red-500", label: "Hoch" };
|
if (priority <= 1) return { color: "bg-cherry", label: "Hoch" };
|
||||||
if (priority <= 2) return { color: "bg-amber-500", label: "Mittel" };
|
if (priority <= 2) return { color: "bg-gold", label: "Mittel" };
|
||||||
if (priority <= 3) return { color: "bg-blue-500", label: "Normal" };
|
if (priority <= 3) return { color: "bg-azure", label: "Normal" };
|
||||||
return { color: "bg-slate-500", label: "Niedrig" };
|
return { color: "bg-base-500", label: "Niedrig" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a due date string to a short German date. */
|
|
||||||
function formatDueDate(iso: string | null): string | null {
|
function formatDueDate(iso: string | null): string | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -30,7 +28,7 @@ function formatDueDate(iso: string | null): string | null {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays < 0) return "Ueberfaellig";
|
if (diffDays < 0) return "Überfällig";
|
||||||
if (diffDays === 0) return "Heute";
|
if (diffDays === 0) return "Heute";
|
||||||
if (diffDays === 1) return "Morgen";
|
if (diffDays === 1) return "Morgen";
|
||||||
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
|
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
|
||||||
|
|
@ -46,12 +44,12 @@ export default function TasksCard({ data }: TasksCardProps) {
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in">
|
<div className="deck-card p-5 animate-fade-in">
|
||||||
<div className="flex items-center gap-3 text-red-400">
|
<div className="flex items-center gap-3 text-cherry">
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<AlertTriangle className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Aufgaben nicht verfuegbar</p>
|
<p className="text-sm font-medium">Aufgaben nicht verfügbar</p>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
|
<p className="text-xs text-base-600 mt-0.5">Verbindung fehlgeschlagen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,19 +60,15 @@ export default function TasksCard({ data }: TasksCardProps) {
|
||||||
const tasks = group?.open ?? [];
|
const tasks = group?.open ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-blue-500/[0.04] to-transparent">
|
<div className="deck-card p-5 animate-fade-in" data-accent="azure">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<CheckSquare className="w-4 h-4 text-azure" />
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
<h3 className="text-sm font-semibold text-base-900">Aufgaben</h3>
|
||||||
<CheckSquare className="w-4 h-4 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-white">Aufgaben</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab buttons */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-1 mb-4">
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const tabGroup = tab.key === "private" ? data.private : data.sams;
|
const tabGroup = tab.key === "private" ? data.private : data.sams;
|
||||||
const openCount = tabGroup?.open_count ?? 0;
|
const openCount = tabGroup?.open_count ?? 0;
|
||||||
|
|
@ -84,23 +78,13 @@ export default function TasksCard({ data }: TasksCardProps) {
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`
|
className={`tab-btn ${isActive ? "active" : ""} flex items-center gap-2`}
|
||||||
flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-medium transition-all duration-200
|
|
||||||
${
|
|
||||||
isActive
|
|
||||||
? "bg-blue-500/15 text-blue-300 border border-blue-500/20"
|
|
||||||
: "bg-white/[0.03] text-slate-400 border border-white/[0.04] hover:bg-white/[0.06] hover:text-slate-300"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{openCount > 0 && (
|
{openCount > 0 && (
|
||||||
<span
|
<span className={`text-[10px] font-bold ${
|
||||||
className={`
|
isActive ? "text-gold" : "text-base-500"
|
||||||
inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[10px] font-bold
|
}`}>
|
||||||
${isActive ? "bg-blue-500/25 text-blue-200" : "bg-white/[0.06] text-slate-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{openCount}
|
{openCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -109,14 +93,14 @@ export default function TasksCard({ data }: TasksCardProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task list */}
|
{/* Tasks */}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-slate-600">
|
<div className="flex flex-col items-center justify-center py-8 text-base-500">
|
||||||
<CheckSquare className="w-8 h-8 mb-2 opacity-30" />
|
<CheckSquare className="w-8 h-8 mb-2 opacity-20" />
|
||||||
<p className="text-xs">Alles erledigt!</p>
|
<p className="text-xs font-mono">Alles erledigt!</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
|
<div className="space-y-px max-h-80 overflow-y-auto">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<TaskItem key={task.id} task={task} />
|
<TaskItem key={task.id} task={task} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -129,44 +113,36 @@ export default function TasksCard({ data }: TasksCardProps) {
|
||||||
function TaskItem({ task }: { task: Task }) {
|
function TaskItem({ task }: { task: Task }) {
|
||||||
const p = priorityIndicator(task.priority);
|
const p = priorityIndicator(task.priority);
|
||||||
const due = formatDueDate(task.due_date);
|
const due = formatDueDate(task.due_date);
|
||||||
const isOverdue = due === "Ueberfaellig";
|
const isOverdue = due === "Überfällig";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 px-3 py-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] transition-colors group">
|
<div className="flex items-start gap-3 px-3 py-2.5 bg-base-100 border-l-2 border-base-300 hover:border-azure transition-colors group">
|
||||||
{/* Visual checkbox */}
|
|
||||||
<div className="mt-0.5 flex-shrink-0">
|
<div className="mt-0.5 flex-shrink-0">
|
||||||
{task.done ? (
|
{task.done ? (
|
||||||
<CheckSquare className="w-4 h-4 text-blue-400" />
|
<CheckSquare className="w-4 h-4 text-azure" />
|
||||||
) : (
|
) : (
|
||||||
<Square className="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
<Square className="w-4 h-4 text-base-400 group-hover:text-base-600 transition-colors" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p
|
<p className={`text-sm leading-snug ${
|
||||||
className={`text-sm leading-snug ${
|
task.done ? "text-base-500 line-through" : "text-base-800"
|
||||||
task.done ? "text-slate-600 line-through" : "text-slate-200"
|
}`}>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||||
{/* Project badge */}
|
|
||||||
{task.project_name && (
|
{task.project_name && (
|
||||||
<span className="badge bg-indigo-500/15 text-indigo-300">
|
<span className="tag border-iris/30 text-iris bg-iris/5">
|
||||||
{task.project_name}
|
{task.project_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Due date */}
|
|
||||||
{due && (
|
{due && (
|
||||||
<span
|
<span className={`flex items-center gap-1 text-[10px] font-mono font-medium ${
|
||||||
className={`flex items-center gap-1 text-[10px] font-medium ${
|
isOverdue ? "text-cherry" : "text-base-500"
|
||||||
isOverdue ? "text-red-400" : "text-slate-500"
|
}`}>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Calendar className="w-2.5 h-2.5" />
|
<Calendar className="w-2.5 h-2.5" />
|
||||||
{due}
|
{due}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -174,9 +150,8 @@ function TaskItem({ task }: { task: Task }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority dot */}
|
|
||||||
<div className="flex-shrink-0 mt-1.5" title={p.label}>
|
<div className="flex-shrink-0 mt-1.5" title={p.label}>
|
||||||
<div className={`w-2 h-2 rounded-full ${p.color}`} />
|
<div className={`w-1.5 h-1.5 ${p.color}`} style={{ borderRadius: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,36 +6,22 @@ interface WeatherCardProps {
|
||||||
accent: "cyan" | "amber";
|
accent: "cyan" | "amber";
|
||||||
}
|
}
|
||||||
|
|
||||||
const accentMap = {
|
const ACCENT = {
|
||||||
cyan: {
|
cyan: { strip: "gold", text: "text-gold", glow: "data-glow-gold", tag: "border-gold/30 text-gold bg-gold/5" },
|
||||||
gradient: "from-cyan-500/10 to-cyan-900/5",
|
amber: { strip: "mint", text: "text-mint", glow: "data-glow-mint", tag: "border-mint/30 text-mint bg-mint/5" },
|
||||||
text: "text-cyan-400",
|
|
||||||
border: "border-cyan-500/20",
|
|
||||||
badge: "bg-cyan-500/15 text-cyan-300",
|
|
||||||
statIcon: "text-cyan-400/70",
|
|
||||||
ring: "ring-cyan-500/10",
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
gradient: "from-amber-500/10 to-amber-900/5",
|
|
||||||
text: "text-amber-400",
|
|
||||||
border: "border-amber-500/20",
|
|
||||||
badge: "bg-amber-500/15 text-amber-300",
|
|
||||||
statIcon: "text-amber-400/70",
|
|
||||||
ring: "ring-amber-500/10",
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
||||||
const a = accentMap[accent];
|
const a = ACCENT[accent];
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-5 animate-fade-in">
|
<div className="deck-card p-5 animate-fade-in">
|
||||||
<div className="flex items-center gap-3 text-red-400">
|
<div className="flex items-center gap-3 text-cherry">
|
||||||
<CloudOff className="w-5 h-5" />
|
<CloudOff className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Wetter nicht verfuegbar</p>
|
<p className="text-sm font-medium">Wetter nicht verfügbar</p>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">{data.location || "Unbekannt"}</p>
|
<p className="text-xs text-base-600 mt-0.5">{data.location || "Unbekannt"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,73 +29,66 @@ export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`glass-card p-5 animate-fade-in bg-gradient-to-br ${a.gradient}`}>
|
<div className="deck-card corner-marks p-6 animate-fade-in" data-accent={a.strip}>
|
||||||
{/* Header: location badge */}
|
{/* Location tag */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-5">
|
||||||
<span className={`badge ${a.badge}`}>{data.location}</span>
|
<span className={`tag ${a.tag}`}>{data.location}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main row: icon + temp */}
|
{/* Temperature hero */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className={`text-5xl font-mono font-bold ${a.text} ${a.glow} tracking-tighter`}>
|
||||||
className="text-5xl font-extrabold text-white tracking-tighter"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
{Math.round(data.temp)}
|
{Math.round(data.temp)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-light text-slate-400">°</span>
|
<span className="text-xl font-light text-base-500">°</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-400 mt-1 capitalize">{data.description}</p>
|
<p className="text-sm text-base-600 mt-1 capitalize font-light">{data.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-5xl select-none" role="img" aria-label="weather">
|
<span className="text-5xl select-none opacity-80" role="img" aria-label="weather">
|
||||||
{data.icon}
|
{data.icon}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat grid */}
|
{/* Stats row */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-px bg-base-300">
|
||||||
<StatItem
|
<StatCell
|
||||||
icon={<Thermometer className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
icon={<Thermometer className="w-3 h-3 text-base-500" />}
|
||||||
label="Gefuehlt"
|
label="Gefühlt"
|
||||||
value={`${Math.round(data.feels_like)}\u00B0`}
|
value={`${Math.round(data.feels_like)}°`}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatCell
|
||||||
icon={<Droplets className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
icon={<Droplets className="w-3 h-3 text-base-500" />}
|
||||||
label="Feuchte"
|
label="Feuchte"
|
||||||
value={`${data.humidity}%`}
|
value={`${data.humidity}%`}
|
||||||
/>
|
/>
|
||||||
<StatItem
|
<StatCell
|
||||||
icon={<Wind className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
icon={<Wind className="w-3 h-3 text-base-500" />}
|
||||||
label="Wind"
|
label="Wind"
|
||||||
value={`${Math.round(data.wind_kmh)} km/h`}
|
value={`${Math.round(data.wind_kmh)}`}
|
||||||
|
unit="km/h"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatItem({
|
function StatCell({ icon, label, value, unit }: {
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
unit?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] border border-white/[0.04] px-2 py-2.5">
|
<div className="flex flex-col items-center gap-1.5 py-3 bg-base-50">
|
||||||
{icon}
|
{icon}
|
||||||
<span
|
<div className="flex items-baseline gap-0.5">
|
||||||
className="text-sm font-semibold text-white"
|
<span className="text-sm data-value text-base-900">{value}</span>
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
{unit && <span className="text-[9px] text-base-500">{unit}</span>}
|
||||||
>
|
</div>
|
||||||
{value}
|
<span className="data-label">{label}</span>
|
||||||
</span>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-500">{label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,135 +14,228 @@ export default function Dashboard() {
|
||||||
const { data, loading, error, connected, refresh } = useDashboard();
|
const { data, loading, error, connected, refresh } = useDashboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-white">
|
<div className="min-h-screen text-base-800">
|
||||||
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-red-500/15 border-b border-red-500/20 backdrop-blur-sm px-4 py-2 animate-slide-up">
|
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-cherry/10 border-b border-cherry/20 px-4 py-2 animate-fade-in">
|
||||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-cherry flex-shrink-0" />
|
||||||
<p className="text-xs text-red-300">{error}</p>
|
<p className="text-xs text-cherry font-mono">{error}</p>
|
||||||
<button onClick={refresh} className="ml-3 text-xs text-red-300 hover:text-white underline underline-offset-2 transition-colors">
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="ml-3 text-xs text-cherry hover:text-base-900 underline underline-offset-2 transition-colors font-mono"
|
||||||
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
|
{/* Header */}
|
||||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
<header className="sticky top-0 z-40 border-b border-base-300 bg-base-50/95 backdrop-blur-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
||||||
<h1 className="text-base sm:text-lg font-bold tracking-tight text-white">Daily Briefing</h1>
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="font-serif italic text-lg sm:text-xl text-base-900 tracking-tight">
|
||||||
|
Daily Briefing
|
||||||
|
</h1>
|
||||||
<LiveIndicator connected={connected} />
|
<LiveIndicator connected={connected} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors disabled:opacity-40"
|
className="flex items-center justify-center w-8 h-8 bg-base-100 border border-base-300 hover:border-base-500 transition-colors disabled:opacity-40"
|
||||||
title="Daten aktualisieren"
|
title="Daten aktualisieren"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw
|
||||||
|
className={`w-3.5 h-3.5 text-base-600 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors"
|
className="flex items-center justify-center w-8 h-8 bg-base-100 border border-base-300 hover:border-base-500 transition-colors"
|
||||||
title="Admin Panel"
|
title="Admin Panel"
|
||||||
>
|
>
|
||||||
<Settings className="w-3.5 h-3.5 text-slate-400" />
|
<Settings className="w-3.5 h-3.5 text-base-600" />
|
||||||
</Link>
|
</Link>
|
||||||
<Clock />
|
<Clock />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<div className="space-y-10">
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
{/* Section 01 — Wetter */}
|
||||||
<WeatherCard data={data.weather.primary} accent="cyan" />
|
<section>
|
||||||
<WeatherCard data={data.weather.secondary} accent="amber" />
|
<div className="section-label">
|
||||||
<div className="md:col-span-2">
|
<span className="section-number">01</span>
|
||||||
<HourlyForecast slots={data.weather.hourly} />
|
<h2 className="section-title">Wetter</h2>
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
|
<WeatherCard data={data.weather.primary} accent="cyan" />
|
||||||
|
<WeatherCard data={data.weather.secondary} accent="amber" />
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<HourlyForecast slots={data.weather.hourly} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
{/* Section 02 — Infrastruktur */}
|
||||||
{data.servers.servers.map((srv) => (
|
<section>
|
||||||
<ServerCard key={srv.name} server={srv} />
|
<div className="section-label">
|
||||||
))}
|
<span className="section-number">02</span>
|
||||||
<HomeAssistant data={data.ha} />
|
<h2 className="section-title">Infrastruktur</h2>
|
||||||
<TasksCard data={data.tasks} />
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
|
{data.servers.servers.map((srv) => (
|
||||||
|
<ServerCard key={srv.name} server={srv} />
|
||||||
|
))}
|
||||||
|
<HomeAssistant data={data.ha} />
|
||||||
|
<TasksCard data={data.tasks} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && (
|
{/* Section 03 — MQTT (conditional) */}
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
{(data.mqtt?.connected ||
|
||||||
<div className="md:col-span-2 xl:col-span-4">
|
(data.mqtt?.entities?.length ?? 0) > 0) && (
|
||||||
<MqttCard data={data.mqtt} />
|
<section>
|
||||||
|
<div className="section-label">
|
||||||
|
<span className="section-number">03</span>
|
||||||
|
<h2 className="section-title">MQTT</h2>
|
||||||
|
<span className="section-rule" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MqttCard data={data.mqtt} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Section 04 — Nachrichten */}
|
||||||
<section>
|
<section>
|
||||||
<NewsGrid data={data.news} />
|
<NewsGrid data={data.news} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="text-center pb-4">
|
{/* Footer */}
|
||||||
<p className="text-[10px] text-slate-700">
|
<footer className="flex items-center justify-between py-4 border-t border-base-300">
|
||||||
|
<p className="text-[10px] font-mono text-base-500">
|
||||||
Letzte Aktualisierung:{" "}
|
Letzte Aktualisierung:{" "}
|
||||||
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
|
<span className="text-base-700">
|
||||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
|
||||||
})}
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] font-mono text-base-500">
|
||||||
|
Daily Briefing v2
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live indicator ─────────────────────────────────────── */
|
||||||
|
|
||||||
function LiveIndicator({ connected }: { connected: boolean }) {
|
function LiveIndicator({ connected }: { connected: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/[0.06]">
|
<div className="flex items-center gap-2 px-2.5 py-1 border border-base-300">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-emerald-400" : "bg-slate-600"}`} />
|
<div
|
||||||
{connected && <div className="absolute inset-0 w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping opacity-50" />}
|
className={`w-1.5 h-1.5 ${connected ? "bg-mint" : "bg-base-500"}`}
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
|
/>
|
||||||
|
{connected && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 w-1.5 h-1.5 bg-mint animate-ping opacity-40"
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] font-medium ${connected ? "text-emerald-400" : "text-slate-600"}`}>
|
<span
|
||||||
|
className={`text-[10px] font-mono font-medium ${
|
||||||
|
connected ? "text-mint" : "text-base-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{connected ? "Live" : "Offline"}
|
{connected ? "Live" : "Offline"}
|
||||||
</span>
|
</span>
|
||||||
{connected ? <Wifi className="w-3 h-3 text-emerald-400/50" /> : <WifiOff className="w-3 h-3 text-slate-600" />}
|
{connected ? (
|
||||||
|
<Wifi className="w-3 h-3 text-mint/50" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-3 h-3 text-base-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Loading skeleton ───────────────────────────────────── */
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-pulse">
|
<div className="space-y-10">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
{/* Section 01 skeleton */}
|
||||||
<SkeletonCard className="h-52" />
|
|
||||||
<SkeletonCard className="h-52" />
|
|
||||||
<SkeletonCard className="h-24 md:col-span-2" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} className="h-72" />)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="h-6 w-32 rounded bg-white/5 mb-4" />
|
<div className="section-label">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<span className="section-number">01</span>
|
||||||
{Array.from({ length: 8 }).map((_, i) => <SkeletonCard key={i} className="h-28" />)}
|
<div className="h-3 w-16 bg-base-200" />
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<SkeletonCard key={`w-${i}`} className="h-52" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section 02 skeleton */}
|
||||||
|
<div>
|
||||||
|
<div className="section-label">
|
||||||
|
<span className="section-number">02</span>
|
||||||
|
<div className="h-3 w-24 bg-base-200" />
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<SkeletonCard key={`s-${i}`} className="h-72" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 04 skeleton */}
|
||||||
|
<div>
|
||||||
|
<div className="section-label">
|
||||||
|
<span className="section-number">04</span>
|
||||||
|
<div className="h-3 w-28 bg-base-200" />
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-px bg-base-300">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<SkeletonCard key={`n-${i}`} className="h-28" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan loader */}
|
||||||
|
<div className="scan-loader" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonCard({ className = "" }: { className?: string }) {
|
function SkeletonCard({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={`glass-card ${className}`}>
|
<div className={`bg-base-50 ${className}`}>
|
||||||
<div className="p-5 space-y-3">
|
<div className="p-5 space-y-3 animate-pulse">
|
||||||
<div className="h-3 w-1/3 rounded bg-white/5" />
|
<div className="h-3 w-1/3 bg-base-200" />
|
||||||
<div className="h-2 w-2/3 rounded bg-white/[0.03]" />
|
<div className="h-2 w-2/3 bg-base-200/60" />
|
||||||
<div className="h-2 w-1/2 rounded bg-white/[0.03]" />
|
<div className="h-2 w-1/2 bg-base-200/60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,115 +8,182 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f172a 50%, #0c1220 100%);
|
background: #0d0d0f;
|
||||||
|
color: #e8e5e0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle film grain overlay */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.022;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-size: 256px 256px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 4px;
|
||||||
height: 6px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(148, 163, 184, 0.2);
|
background: #2a2a2e;
|
||||||
border-radius: 3px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(148, 163, 184, 0.4);
|
background: #38383d;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #e8a44a40;
|
||||||
|
color: #e8e5e0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.glass-card {
|
/* ── Card System ─────────────────────────────────────────── */
|
||||||
@apply relative overflow-hidden rounded-2xl border border-white/[0.06]
|
.deck-card {
|
||||||
bg-white/[0.03] backdrop-blur-md shadow-xl shadow-black/20;
|
@apply relative bg-base-50 border border-base-300 overflow-hidden;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card-hover {
|
.deck-card::before {
|
||||||
@apply glass-card transition-all duration-300
|
|
||||||
hover:border-white/[0.12] hover:bg-white/[0.05]
|
|
||||||
hover:shadow-2xl hover:shadow-black/30
|
|
||||||
hover:-translate-y-0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card::before {
|
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
border-radius: inherit;
|
left: 0;
|
||||||
background: linear-gradient(
|
width: 2px;
|
||||||
135deg,
|
height: 100%;
|
||||||
rgba(255, 255, 255, 0.04) 0%,
|
background: #2a2a2e;
|
||||||
transparent 50%
|
transition: background 0.2s ease;
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accent-glow {
|
.deck-card:hover::before {
|
||||||
position: relative;
|
background: #e8a44a;
|
||||||
}
|
}
|
||||||
.accent-glow::after {
|
|
||||||
|
.deck-card[data-accent="gold"]::before { background: #e8a44a; }
|
||||||
|
.deck-card[data-accent="mint"]::before { background: #4ae8a8; }
|
||||||
|
.deck-card[data-accent="cherry"]::before { background: #e85a5a; }
|
||||||
|
.deck-card[data-accent="iris"]::before { background: #a87aec; }
|
||||||
|
.deck-card[data-accent="azure"]::before { background: #5ab4e8; }
|
||||||
|
|
||||||
|
/* Corner markers — technical drawing accent */
|
||||||
|
.corner-marks {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
.corner-marks::before,
|
||||||
|
.corner-marks::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -1px;
|
width: 8px;
|
||||||
border-radius: inherit;
|
height: 8px;
|
||||||
opacity: 0;
|
border-color: #4a4a50;
|
||||||
transition: opacity 0.3s;
|
border-style: solid;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.accent-glow:hover::after {
|
.corner-marks::before {
|
||||||
opacity: 1;
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
border-width: 1px 1px 0 0;
|
||||||
|
}
|
||||||
|
.corner-marks::after {
|
||||||
|
bottom: -1px;
|
||||||
|
left: -1px;
|
||||||
|
border-width: 0 0 1px 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
/* ── Section Headers ─────────────────────────────────────── */
|
||||||
@apply text-3xl font-bold tracking-tight;
|
.section-label {
|
||||||
font-family: "JetBrains Mono", monospace;
|
@apply flex items-center gap-3 mb-5;
|
||||||
|
}
|
||||||
|
.section-number {
|
||||||
|
@apply font-mono text-[10px] font-semibold tracking-widest text-base-500 uppercase;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
@apply font-serif italic text-lg text-base-900 tracking-tight;
|
||||||
|
}
|
||||||
|
.section-rule {
|
||||||
|
@apply flex-1 h-px bg-base-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
/* ── Data Values ─────────────────────────────────────────── */
|
||||||
@apply text-xs font-medium uppercase tracking-wider text-slate-400;
|
.data-value {
|
||||||
|
@apply font-mono font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
.data-label {
|
||||||
|
@apply text-[10px] font-mono uppercase tracking-[0.15em] text-base-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.data-glow-gold { text-shadow: 0 0 20px #e8a44a33, 0 0 40px #e8a44a15; }
|
||||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px]
|
.data-glow-mint { text-shadow: 0 0 20px #4ae8a833, 0 0 40px #4ae8a815; }
|
||||||
font-semibold uppercase tracking-wider;
|
.data-glow-cherry { text-shadow: 0 0 20px #e85a5a33, 0 0 40px #e85a5a15; }
|
||||||
|
|
||||||
|
/* ── Tags/Badges ─────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 text-[10px] font-mono font-medium uppercase tracking-wider border;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
/* ── Tabs ─────────────────────────────────────────────────── */
|
||||||
@apply h-1.5 rounded-full bg-slate-800 overflow-hidden;
|
.tab-btn {
|
||||||
|
@apply px-3 py-1.5 text-xs font-mono font-medium tracking-wider uppercase transition-all duration-150 cursor-pointer select-none border;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
@apply bg-gold/10 border-gold/40 text-gold;
|
||||||
|
}
|
||||||
|
.tab-btn:not(.active) {
|
||||||
|
@apply bg-transparent border-base-300 text-base-600 hover:text-base-800 hover:border-base-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
/* ── Progress ────────────────────────────────────────────── */
|
||||||
@apply h-full rounded-full transition-all duration-700 ease-out;
|
.bar-track {
|
||||||
|
@apply h-1 bg-base-200 overflow-hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
@apply h-full transition-all duration-700 ease-out;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tab {
|
/* ── Scan-line loader ────────────────────────────────────── */
|
||||||
@apply px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
|
.scan-loader {
|
||||||
cursor-pointer select-none;
|
@apply h-px bg-base-200 overflow-hidden relative;
|
||||||
}
|
}
|
||||||
.category-tab.active {
|
.scan-loader::after {
|
||||||
@apply bg-white/10 text-white;
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, #e8a44a, transparent);
|
||||||
|
animation: scan 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.category-tab:not(.active) {
|
|
||||||
@apply text-slate-400 hover:text-slate-200 hover:bg-white/5;
|
/* ── Dividers ────────────────────────────────────────────── */
|
||||||
|
.divider {
|
||||||
|
@apply border-none h-px bg-base-300 my-6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-gradient {
|
|
||||||
@apply bg-clip-text text-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-3 {
|
.line-clamp-3 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,73 @@ export default {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
surface: {
|
base: {
|
||||||
50: "rgba(255,255,255,0.05)",
|
DEFAULT: "#0d0d0f",
|
||||||
100: "rgba(255,255,255,0.08)",
|
50: "#161618",
|
||||||
200: "rgba(255,255,255,0.12)",
|
100: "#1c1c1f",
|
||||||
|
200: "#232326",
|
||||||
|
300: "#2a2a2e",
|
||||||
|
400: "#38383d",
|
||||||
|
500: "#4a4a50",
|
||||||
|
600: "#6b6966",
|
||||||
|
700: "#8a8783",
|
||||||
|
800: "#b0ada8",
|
||||||
|
900: "#e8e5e0",
|
||||||
|
},
|
||||||
|
gold: {
|
||||||
|
DEFAULT: "#e8a44a",
|
||||||
|
dim: "#e8a44a80",
|
||||||
|
glow: "#e8a44a33",
|
||||||
|
muted: "#c4884a",
|
||||||
|
},
|
||||||
|
mint: {
|
||||||
|
DEFAULT: "#4ae8a8",
|
||||||
|
dim: "#4ae8a880",
|
||||||
|
glow: "#4ae8a833",
|
||||||
|
},
|
||||||
|
cherry: {
|
||||||
|
DEFAULT: "#e85a5a",
|
||||||
|
dim: "#e85a5a80",
|
||||||
|
glow: "#e85a5a33",
|
||||||
|
},
|
||||||
|
iris: {
|
||||||
|
DEFAULT: "#a87aec",
|
||||||
|
dim: "#a87aec80",
|
||||||
|
glow: "#a87aec33",
|
||||||
|
},
|
||||||
|
azure: {
|
||||||
|
DEFAULT: "#5ab4e8",
|
||||||
|
dim: "#5ab4e880",
|
||||||
|
glow: "#5ab4e833",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
backdropBlur: {
|
fontFamily: {
|
||||||
xs: "2px",
|
serif: ['"Instrument Serif"', "Georgia", "serif"],
|
||||||
|
sans: ['"IBM Plex Sans"', "system-ui", "sans-serif"],
|
||||||
|
mono: ['"IBM Plex Mono"', "Menlo", "monospace"],
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"fade-in": "fadeIn 0.5s ease-out",
|
"fade-in": "fadeIn 0.4s ease-out both",
|
||||||
"slide-up": "slideUp 0.4s ease-out",
|
"slide-in": "slideIn 0.3s ease-out both",
|
||||||
"pulse-slow": "pulse 3s ease-in-out infinite",
|
"scan": "scan 1.5s ease-in-out infinite",
|
||||||
|
"glow-pulse": "glowPulse 2s ease-in-out infinite",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
"0%": { opacity: "0", transform: "translateY(8px)" },
|
"0%": { opacity: "0", transform: "translateY(6px)" },
|
||||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
},
|
},
|
||||||
slideUp: {
|
slideIn: {
|
||||||
"0%": { opacity: "0", transform: "translateY(16px)" },
|
"0%": { opacity: "0", transform: "translateX(-8px)" },
|
||||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
"100%": { opacity: "1", transform: "translateX(0)" },
|
||||||
|
},
|
||||||
|
scan: {
|
||||||
|
"0%, 100%": { transform: "translateX(-100%)" },
|
||||||
|
"50%": { transform: "translateX(100%)" },
|
||||||
|
},
|
||||||
|
glowPulse: {
|
||||||
|
"0%, 100%": { opacity: "0.5" },
|
||||||
|
"50%": { opacity: "1" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue