Repository Public
1"use client";23import { useState } from "react";4import { getDict, type Locale } from "@/lib/i18n";56type BlockKey = "studio" | "mini" | "laptop" | "switch" | "gateway" | "storage" | "external" | "internet";78function Trace({ dashed = false, active = false, height = 28 }: { dashed?: boolean; active?: boolean; height?: number }) {9 return (10 <svg width="2" height={height} viewBox={`0 0 2 ${height}`} aria-hidden="true" className="mx-auto block overflow-visible">11 <line x1="1" y1="0" x2="1" y2={height} stroke={active ? "#1D5FD1" : "#0C1322"} strokeOpacity={active ? 1 : 0.28} strokeWidth={active ? 1.6 : 1} className={dashed || active ? "trace" : undefined} strokeDasharray={dashed ? "4 7" : undefined} />12 </svg>13 );14}1516function Block({ k, title, sub, tone = "node", sel, setSel }: { k: BlockKey; title: string; sub?: string; tone?: "node" | "ext"; sel: BlockKey; setSel: (k: BlockKey) => void }) {17 return (18 <button19 type="button"20 aria-pressed={sel === k}21 onClick={() => setSel(k)}22 onMouseEnter={() => setSel(k)}23 onFocus={() => setSel(k)}24 className={`group w-full rounded-xl border px-3 py-3 text-left transition-colors md:px-4 md:py-4 ${sel === k ? "border-accent bg-accent-soft" : tone === "ext" ? "border-dashed border-line-strong bg-transparent hover:border-accent" : "border-line-strong bg-bg hover:border-accent"}`}25 >26 <span className="block text-[0.8125rem] font-medium leading-tight text-fg md:text-[0.9375rem]">{title}</span>27 {sub ? <span className="num mt-1.5 block font-mono text-[0.6875rem] tracking-[0.06em] text-mute">{sub}</span> : null}28 </button>29 );30}3132/**33 * Simplified, interactive map of the cluster. It shows groups and roles only — counts come from the inventory,34 * and no address, hostname or physical wiring is represented.35 */36export function Topology({ locale, counts, linkRate }: { locale: Locale; counts: { studio: number; mini: number; laptop: number }; linkRate: string }) {37 const t = getDict(locale).topology;38 const [sel, setSel] = useState<BlockKey>("switch");39 const lan: BlockKey[] = ["studio", "mini", "laptop", "gateway", "storage"];40 const isLanActive = (k: BlockKey) => sel === k || (sel === "switch" && lan.includes(k));41 const tunnelActive = sel === "external" || sel === "gateway" || sel === "internet";4243 return (44 <div className="grid gap-8 lg:grid-cols-12 lg:gap-10">45 <div className="lg:col-span-8" role="group" aria-label={t.alt}>46 <div className="relative rounded-2xl border border-dashed border-line-strong p-4 pt-9 md:p-7 md:pt-11">47 <span className="label absolute left-4 top-3 md:left-7">{t.legend.boundary}</span>48 <div className="grid grid-cols-3 gap-2.5 md:gap-5">49 {(["studio", "mini", "laptop"] as const).map((k) => (50 <div key={k}>51 <Block k={k} title={t.blocks[k].title} sub={t.count(counts[k])} sel={sel} setSel={setSel} />52 <Trace active={isLanActive(k)} />53 </div>54 ))}55 </div>56 <button57 type="button"58 aria-pressed={sel === "switch"}59 onClick={() => setSel("switch")}60 onMouseEnter={() => setSel("switch")}61 onFocus={() => setSel("switch")}62 className={`flex w-full items-center justify-between gap-3 rounded-xl border px-4 py-2.5 text-left transition-colors ${sel === "switch" ? "border-accent bg-accent-soft" : "border-line-strong bg-surface-2 hover:border-accent"}`}63 >64 <span className="text-[0.8125rem] font-medium md:text-[0.9375rem]">{t.blocks.switch.title}</span>65 <span className="num font-mono text-[0.6875rem] tracking-[0.06em] text-accent">{linkRate}</span>66 </button>67 <div className="grid grid-cols-2 gap-2.5 md:gap-5">68 {(["gateway", "storage"] as const).map((k) => (69 <div key={k}>70 <Trace active={isLanActive(k)} />71 <Block k={k} title={t.blocks[k].title} sel={sel} setSel={setSel} />72 </div>73 ))}74 </div>75 </div>76 <div className="grid grid-cols-2 gap-2.5 md:gap-5">77 <div>78 <Trace dashed active={tunnelActive} height={40} />79 <Block k="external" title={t.blocks.external.title} tone="ext" sel={sel} setSel={setSel} />80 </div>81 <div className="flex flex-col justify-end">82 <Block k="internet" title={t.blocks.internet.title} tone="ext" sel={sel} setSel={setSel} />83 </div>84 </div>85 <ul className="mt-6 flex flex-wrap gap-x-7 gap-y-2 font-mono text-[0.6875rem] tracking-[0.06em] text-mute">86 <li className="flex items-center gap-2"><span className="h-px w-6 bg-fg/40" aria-hidden="true" />{t.legend.lan}</li>87 <li className="flex items-center gap-2"><span className="h-px w-6 border-t border-dashed border-fg/50" aria-hidden="true" />{t.legend.tunnel}</li>88 </ul>89 </div>90 <div className="lg:col-span-4">91 <div className="card !bg-bg p-6 md:p-7" aria-live="polite">92 <p className="label">{String(["studio", "mini", "laptop", "switch", "gateway", "storage", "external", "internet"].indexOf(sel) + 1).padStart(2, "0")} / 08</p>93 <h3 className="mt-4 text-xl font-medium tracking-[-0.02em]">{t.blocks[sel].title}</h3>94 <p className="mt-3 text-[0.9375rem] leading-relaxed text-body">{t.blocks[sel].body}</p>95 </div>96 </div>97 </div>98 );99}100