Import source: web/src/components/Header
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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 (
|
||||||
|
<span className="tabular text-paper-dim text-[13px] font-mono">
|
||||||
|
{fmtClock(lang, now)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<TargetState, number>();
|
||||||
|
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 (
|
||||||
|
<div className="hidden md:flex items-center gap-3 font-mono text-[10px]">
|
||||||
|
{parts.map((s) => (
|
||||||
|
<span key={s} className="flex items-center gap-1.5">
|
||||||
|
<span className="size-[5px] rounded-full" style={{ background: STATE_COLOR[s] }} />
|
||||||
|
<span className="tabular text-paper-dim">{fmtNumber(lang, counts.get(s)!)}</span>
|
||||||
|
<span className="text-paper-faint">{t.state[s]}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
const { reduced } = useUI();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex items-center border border-ink-700/60 rounded-full p-[3px] gap-[2px]"
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={`relative px-3 py-1 rounded-full text-[10px] font-mono tracking-[0.14em] uppercase transition-colors cursor-pointer ${
|
||||||
|
active ? "text-ink-950" : "text-paper-faint hover:text-paper-dim"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId={`seg-${ariaLabel}`}
|
||||||
|
className="absolute inset-0 rounded-full bg-brass"
|
||||||
|
transition={reduced ? { duration: 0 } : springs.snappy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { t, theme, setTheme, reduced } = useUI();
|
||||||
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
onClick={() => 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}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width={13} height={13} aria-hidden>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
// sun
|
||||||
|
<g stroke="currentColor" strokeWidth={1.2} strokeLinecap="round">
|
||||||
|
<circle cx={8} cy={8} r={3.1} fill="none" />
|
||||||
|
{Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const a = (i * Math.PI) / 4;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={8 + Math.cos(a) * 5}
|
||||||
|
y1={8 + Math.sin(a) * 5}
|
||||||
|
x2={8 + Math.cos(a) * 6.6}
|
||||||
|
y2={8 + Math.sin(a) * 6.6}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
// moon
|
||||||
|
<path
|
||||||
|
d="M 13.2 9.8 A 5.6 5.6 0 1 1 6.2 2.8 A 4.6 4.6 0 0 0 13.2 9.8 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
connected,
|
||||||
|
targets,
|
||||||
|
}: {
|
||||||
|
connected: boolean;
|
||||||
|
targets: LiveTarget[];
|
||||||
|
}) {
|
||||||
|
const { t, lang, mode, window, setLang, setMode, setWindow, reduced } =
|
||||||
|
useUI();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="relative z-10 flex flex-wrap items-center gap-x-6 gap-y-4 px-6 sm:px-10 pt-7 pb-5">
|
||||||
|
{/* wordmark */}
|
||||||
|
<div className="flex items-baseline gap-3 me-auto">
|
||||||
|
<span
|
||||||
|
className="text-[28px] leading-none text-paper"
|
||||||
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
|
Dash
|
||||||
|
</span>
|
||||||
|
<span className="microlabel hidden sm:inline">{t.tagline}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusSummary targets={targets} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
{/* connection state */}
|
||||||
|
<div className="flex items-center gap-2" title={connected ? t.live : t.connecting}>
|
||||||
|
<span className="relative flex size-[7px]">
|
||||||
|
{connected && !reduced && (
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0 rounded-full bg-up"
|
||||||
|
animate={{ scale: [1, 2.4], opacity: [0.6, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex size-[7px] rounded-full ${
|
||||||
|
connected ? "bg-up" : "bg-warn"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="microlabel">{connected ? t.live : t.connecting}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LiveClock />
|
||||||
|
|
||||||
|
{mode === "internal" && (
|
||||||
|
<Segmented<Window>
|
||||||
|
ariaLabel="window"
|
||||||
|
value={window}
|
||||||
|
onChange={setWindow}
|
||||||
|
options={[
|
||||||
|
{ value: "24h", label: lang === "fa" ? "۲۴س" : "24H" },
|
||||||
|
{ value: "7d", label: lang === "fa" ? "۷ر" : "7D" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Segmented<ViewMode>
|
||||||
|
ariaLabel="mode"
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
options={[
|
||||||
|
{ value: "internal", label: t.internalMode },
|
||||||
|
{ value: "public", label: t.publicMode },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Segmented<"en" | "fa">
|
||||||
|
ariaLabel="language"
|
||||||
|
value={lang}
|
||||||
|
onChange={setLang}
|
||||||
|
options={[
|
||||||
|
{ value: "en", label: "EN" },
|
||||||
|
{ value: "fa", label: "فا" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user