diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..6c02a75 --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from "react"; +import { motion } from "motion/react"; +import type { LiveTarget, TargetState } from "../lib/types"; +import { fmtClock, fmtNumber } from "../lib/format"; +import { springs, STATE_COLOR, useUI, type ViewMode, type Window } from "../lib/ui"; + +function LiveClock() { + const { lang } = useUI(); + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + return ( + + {fmtClock(lang, now)} + + ); +} + +/** Live fleet summary: 24 up · 2 degraded · 1 down. Zero counts hide. */ +function StatusSummary({ targets }: { targets: LiveTarget[] }) { + const { t, lang } = useUI(); + const counts = new Map(); + targets.forEach((x) => counts.set(x.state, (counts.get(x.state) ?? 0) + 1)); + const order: TargetState[] = ["up", "degraded", "down", "unknown"]; + const parts = order.filter((s) => (counts.get(s) ?? 0) > 0); + if (parts.length === 0) return null; + return ( +
+ {parts.map((s) => ( + + + {fmtNumber(lang, counts.get(s)!)} + {t.state[s]} + + ))} +
+ ); +} + +function Segmented({ + value, + options, + onChange, + ariaLabel, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; + ariaLabel: string; +}) { + const { reduced } = useUI(); + return ( +
+ {options.map((opt) => { + const active = opt.value === value; + return ( + + ); + })} +
+ ); +} + +function ThemeToggle() { + const { t, theme, setTheme, reduced } = useUI(); + const next = theme === "dark" ? "light" : "dark"; + return ( + setTheme(next)} + aria-label={theme === "dark" ? t.themeLight : t.themeDark} + title={theme === "dark" ? t.themeLight : t.themeDark} + className="size-[30px] rounded-full border border-ink-700/60 text-paper-dim hover:text-paper hover:border-ink-700 cursor-pointer flex items-center justify-center" + whileTap={reduced ? undefined : { scale: 0.88, rotate: 40 }} + transition={springs.snappy} + > + + {theme === "dark" ? ( + // sun + + + {Array.from({ length: 8 }, (_, i) => { + const a = (i * Math.PI) / 4; + return ( + + ); + })} + + ) : ( + // moon + + )} + + + ); +} + +export function Header({ + connected, + targets, +}: { + connected: boolean; + targets: LiveTarget[]; +}) { + const { t, lang, mode, window, setLang, setMode, setWindow, reduced } = + useUI(); + + return ( +
+ {/* wordmark */} +
+ + Dash + + {t.tagline} +
+ + + +
+ {/* connection state */} +
+ + {connected && !reduced && ( + + )} + + + {connected ? t.live : t.connecting} +
+ + + + {mode === "internal" && ( + + ariaLabel="window" + value={window} + onChange={setWindow} + options={[ + { value: "24h", label: lang === "fa" ? "۲۴س" : "24H" }, + { value: "7d", label: lang === "fa" ? "۷ر" : "7D" }, + ]} + /> + )} + + + ariaLabel="mode" + value={mode} + onChange={setMode} + options={[ + { value: "internal", label: t.internalMode }, + { value: "public", label: t.publicMode }, + ]} + /> + + + ariaLabel="language" + value={lang} + onChange={setLang} + options={[ + { value: "en", label: "EN" }, + { value: "fa", label: "فا" }, + ]} + /> + + +
+
+ ); +}