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