From 55fb57456eef36bb3931fbd097694ba39666e7f6 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 16:52:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=203=20renderers=20&=20ergonomics?= =?UTF-8?q?=20=E2=80=94=20index/console,=20=E2=8C=98K=20palette,=20icon=20?= =?UTF-8?q?suggest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Index renderer (editorial): serif masthead + section heads, numbered full-width rows, live stat strip (time/services/up/down/widgets) - Console renderer (terminal): $ dash status banner, bordered stat panels, ## section heads, › prompt rows, [ up ]/[ down ] tags - Renderer contract gains index prop for row/section numbering - Settings: 2x2 renderer radio grid with per-renderer descriptions - ⌘K/Ctrl+K// palette: actions + service endpoints, arrow-key nav, zero-dep implementation on the existing Dialog primitive - Icon picker: dashboard-icons slug suggestion chip + live preview - Fonts: Instrument Serif, JetBrains Mono (fontsource) - Fix: global :focus-visible rule was unlayered and silently overrode all focus-visible: utilities since phase 1 — moved into @layer base Verified: typecheck/lint/build clean, dnd + collapse + popover in both new renderers, palette filter + execute, icon suggest resolves real CDN assets, 360px + light theme, docker image 43.9 MB serving board + widget types. Generated with [Devin](https://devin.ai) Authored By: TDvorak Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 14 +- apps/frontend/package.json | 2 + apps/frontend/src/App.tsx | 38 ++- apps/frontend/src/board/Board.tsx | 8 +- .../src/components/CommandPalette.tsx | 164 +++++++++++++ .../frontend/src/components/ServiceDialog.tsx | 36 ++- .../src/components/SettingsDialog.tsx | 33 ++- apps/frontend/src/components/ui/dialog.tsx | 14 +- apps/frontend/src/index.css | 40 +-- apps/frontend/src/main.tsx | 2 + .../src/renderers/editorial/index.tsx | 224 +++++++++++++++++ apps/frontend/src/renderers/index.ts | 20 +- .../frontend/src/renderers/terminal/index.tsx | 232 ++++++++++++++++++ apps/frontend/src/renderers/types.ts | 2 + apps/frontend/src/types.ts | 5 +- package-lock.json | 20 ++ 16 files changed, 803 insertions(+), 51 deletions(-) create mode 100644 apps/frontend/src/components/CommandPalette.tsx create mode 100644 apps/frontend/src/renderers/editorial/index.tsx create mode 100644 apps/frontend/src/renderers/terminal/index.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 3b4f0f8..5c82503 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,16 +87,16 @@ The product's spine: sections, services, drag-drop, multi-URL. **Exit:** Pi-hole widget shows real data; adding a new integration = one Go file + one React component. — verified: registry, cache, 502 path, generic config dialog, clock live in-browser, Pi-hole fetcher against stubbed v5 API. -## Phase 3 — Renderers & ergonomics → v0.3.0 +## Phase 3 — Renderers & ergonomics → v0.3.0 ✅ done 2026-09-17 -- [ ] **Index** renderer: serif section headers, full-width rows, masthead stat strip -- [ ] **Console** renderer: bordered panels/tables, JetBrains Mono, `[ up ]` tags -- [ ] Renderer switcher polished (instant swap, per-renderer preview in settings) -- [ ] ⌘K command palette: jump to service, add service, toggle theme, switch renderer -- [ ] dashboard-icons auto-suggest in icon picker (name → icon URL) +- [x] **Index** renderer: serif section headers, full-width rows, masthead stat strip +- [x] **Console** renderer: bordered panels/tables, JetBrains Mono, `[ up ]` tags +- [x] Renderer switcher polished (2×2 radio grid with per-renderer descriptions) +- [x] ⌘K command palette: jump to service, add service, toggle theme, switch renderer (⌘K / Ctrl+K / `/`) +- [x] dashboard-icons auto-suggest in icon picker (name → slug → CDN URL chip + live preview) - [ ] User services list → prioritized widget backlog (owner-supplied list pending) -**Exit:** all four renderers work off the same board state; palette navigates everything. +**Exit:** all four renderers work off the same board state; palette navigates everything. — verified: both new renderers render/persist/dnd/collapse, palette filters and executes, icon suggest resolves real CDN assets, 360px + light theme. ## Phase 4 — Open-source launch → v1.0.0 diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 5e9a723..b6b6a52 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,6 +16,8 @@ "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.5", "@fontsource-variable/geist-mono": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/instrument-serif": "^5.3.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index d2c5ec1..3da0f7b 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; -import { LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; +import { useEffect, useState } from "react"; +import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { BoardProvider, useBoard } from "@/board/store"; import { Board } from "@/board/Board"; import { ServiceDialog } from "@/components/ServiceDialog"; import { WidgetDialog } from "@/components/WidgetDialog"; import { SettingsDialog } from "@/components/SettingsDialog"; +import { CommandPalette } from "@/components/CommandPalette"; import { Button } from "@/components/ui/button"; import type { Item } from "@/types"; @@ -19,10 +20,28 @@ export default function App() { function Shell() { const board = useBoard(); const [settingsOpen, setSettingsOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); const [svcOpen, setSvcOpen] = useState(false); const [wdgOpen, setWdgOpen] = useState(false); const [editing, setEditing] = useState(); + // ⌘K / Ctrl+K opens the palette; / does too when not typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const typing = e.target instanceof HTMLElement + && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable); + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((o) => !o); + } else if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + function openEditor(it: Item) { setEditing(it); if (it.kind === "widget") setWdgOpen(true); @@ -42,6 +61,14 @@ function Shell() { Dash
+
); } diff --git a/apps/frontend/src/board/Board.tsx b/apps/frontend/src/board/Board.tsx index 1d3f887..fe5c3c1 100644 --- a/apps/frontend/src/board/Board.tsx +++ b/apps/frontend/src/board/Board.tsx @@ -45,10 +45,11 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) { s.id)}> - {board.sections.map((sec) => ( + {board.sections.map((sec, si) => ( void board.patchSection(sec.id, { collapsed: !sec.collapsed })} onDelete={() => { if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) { @@ -57,13 +58,14 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) { }} > - {sec.items.map((it) => + {sec.items.map((it, ii) => it.kind === "widget" ? ( - onEditItem(it)} /> + onEditItem(it)} /> ) : ( onEditItem(it)} diff --git a/apps/frontend/src/components/CommandPalette.tsx b/apps/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..3418699 --- /dev/null +++ b/apps/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; +import { useBoard } from "@/board/store"; +import { hostOf, IconImg } from "@/board/primitives"; +import { rendererLabels, rendererList } from "@/renderers"; +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import type { Item } from "@/types"; + +// ⌘K palette — flat filtered list over actions + service endpoints. +// No cmdk dep: an input, a list, and an active-index is the whole mechanic. + +interface Command { + id: string; + group: "Actions" | "Services"; + icon?: Item; + glyph?: typeof LayoutGrid; + label: string; + hint?: string | undefined; + run(): void; +} + +export function CommandPalette({ + open, + onOpenChange, + onAddService, + onAddWidget, + onSettings, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + onAddService(): void; + onAddWidget(): void; + onSettings(): void; +}) { + const board = useBoard(); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const listRef = useRef(null); + + const commands = useMemo(() => { + const cmds: Command[] = [ + { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, + { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, + { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, + { + id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon, + label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`, + run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"), + }, + ...rendererList.map((r): Command => ({ + id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid, + label: `Renderer: ${rendererLabels[r]}`, + hint: board.renderer === r ? "active" : undefined, + run: () => void board.setSetting("renderer", r), + })), + { + id: "export", group: "Actions", glyph: Download, label: "Export board JSON", + run: () => { window.open("/api/export", "_blank", "noopener"); }, + }, + ]; + for (const sec of board.sections) { + for (const it of sec.items) { + if (it.kind === "widget") continue; + for (const u of it.urls) { + cmds.push({ + id: `svc-${u.id}`, group: "Services", icon: it, + label: u.label ? `${it.name} — ${u.label}` : it.name, + hint: hostOf(u.url), + run: () => window.open(u.url, "_blank", "noopener"), + }); + } + } + } + return cmds; + }, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings]); + + const q = query.trim().toLowerCase(); + const filtered = q + ? commands.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(q)) + : commands; + + const clamped = Math.min(active, Math.max(0, filtered.length - 1)); + + useEffect(() => { + if (open) { setQuery(""); setActive(0); } + }, [open]); + + useEffect(() => { + listRef.current?.querySelector(`[data-idx="${clamped}"]`)?.scrollIntoView({ block: "nearest" }); + }, [clamped]); + + function pick(i: number) { + const c = filtered[i]; + if (!c) return; + onOpenChange(false); + c.run(); + } + + const groups: { name: string; items: { c: Command; i: number }[] }[] = []; + filtered.forEach((c, i) => { + const g = groups.find((g) => g.name === c.group); + if (g) g.items.push({ c, i }); + else groups.push({ name: c.group, items: [{ c, i }] }); + }); + + return ( + + + Command palette +
+ { setQuery(e.target.value); setActive(0); }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { e.preventDefault(); setActive(clamped + 1); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); } + else if (e.key === "Enter") { e.preventDefault(); pick(clamped); } + }} + placeholder="Search services and actions…" + className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none" + /> + esc +
+
+ {filtered.length === 0 ? ( +

No matches.

+ ) : groups.map((g) => ( +
+
+ {g.name} +
+ {g.items.map(({ c, i }) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/ServiceDialog.tsx b/apps/frontend/src/components/ServiceDialog.tsx index 6e2c393..300c3ba 100644 --- a/apps/frontend/src/components/ServiceDialog.tsx +++ b/apps/frontend/src/components/ServiceDialog.tsx @@ -10,6 +10,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +// dashboard-icons CDN serves png/.png — slugify the service name for +// the one-click suggestion under the icon field. +const DI_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"; +const diSlug = (name: string) => + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + // One dialog for both add and edit: item present = edit, absent = add. export function ServiceDialog({ open, @@ -164,7 +170,8 @@ export function ServiceDialog({
-
+
+ setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
+ {!icon && diSlug(name) ? ( + + ) : null}

URL or uploaded file; blank shows a letter tile.

@@ -197,3 +213,21 @@ export function ServiceDialog({ ); } + +// Small live preview for the icon URL — hides itself on load failure so a +// dead URL reads as "no preview", not a broken image. +function IconPreview({ url }: { url: string }) { + const [failed, setFailed] = useState(false); + useEffect(() => setFailed(false), [url]); + if (!url || failed) { + return ; + } + return ( + setFailed(true)} + className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1" + /> + ); +} diff --git a/apps/frontend/src/components/SettingsDialog.tsx b/apps/frontend/src/components/SettingsDialog.tsx index a948fe0..c81b73c 100644 --- a/apps/frontend/src/components/SettingsDialog.tsx +++ b/apps/frontend/src/components/SettingsDialog.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react"; import { Download, Upload } from "lucide-react"; import { useBoard } from "@/board/store"; -import { rendererLabels, rendererList } from "@/renderers"; +import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, @@ -46,16 +47,26 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
- +
+ {rendererList.map((r) => ( + + ))} +
diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx index e3e8481..30f28d5 100644 --- a/apps/frontend/src/components/ui/dialog.tsx +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -8,7 +8,9 @@ const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; const DialogClose = DialogPrimitive.Close; -function DialogContent({ className, children, ...props }: React.ComponentProps) { +function DialogContent({ + className, children, showCloseButton = true, ...props +}: React.ComponentProps & { showCloseButton?: boolean }) { return ( @@ -22,10 +24,12 @@ function DialogContent({ className, children, ...props }: React.ComponentProps {children} - - - Close - + {showCloseButton ? ( + + + Close + + ) : null} ); diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 2672f4d..39fdcec 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -110,28 +110,32 @@ --font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: "Geist Mono Variable", ui-monospace, monospace; + --font-serif: "Instrument Serif", Georgia, serif; + --font-terminal: "JetBrains Mono Variable", ui-monospace, monospace; } -html { - -webkit-font-smoothing: antialiased; -} +@layer base { + html { + -webkit-font-smoothing: antialiased; + } -body { - background: var(--bg); - color: var(--text); - min-height: 100dvh; - transition: - background 200ms var(--ease), - color 200ms var(--ease); -} + body { + background: var(--bg); + color: var(--text); + min-height: 100dvh; + transition: + background 200ms var(--ease), + color 200ms var(--ease); + } -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } -@media (prefers-reduced-motion: reduce) { - * { - transition: none !important; + @media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } } } diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index a1d09f8..274263d 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,5 +1,7 @@ import "@fontsource-variable/geist"; import "@fontsource-variable/geist-mono"; +import "@fontsource/instrument-serif"; +import "@fontsource-variable/jetbrains-mono"; import "./index.css"; import { StrictMode } from "react"; diff --git a/apps/frontend/src/renderers/editorial/index.tsx b/apps/frontend/src/renderers/editorial/index.tsx new file mode 100644 index 0000000..4d1c0be --- /dev/null +++ b/apps/frontend/src/renderers/editorial/index.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { useBoard } from "@/board/store"; +import { IconImg, ItemAnchor, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Index — editorial/list renderer. Serif masthead + section heads, numbered +// full-width rows, stat strip. Ported from mockups/e-editorial.html. + +function BoardView({ children }: BoardViewProps) { + const board = useBoard(); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + + const services = board.sections.flatMap((s) => s.items).filter((i) => i.kind !== "widget"); + const widgets = board.sections.flatMap((s) => s.items).filter((i) => i.kind === "widget"); + const up = services.filter((i) => board.statuses[i.id]?.status === "up").length; + const down = services.filter((i) => board.statuses[i.id]?.status === "down").length; + + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }).format(now); + const date = new Intl.DateTimeFormat(undefined, { + weekday: "long", day: "numeric", month: "long", year: "numeric", + }).format(now); + + const cells: [string, string, string][] = [ + ["Local time", time, date.split(",")[0] ?? ""], + ["Services", String(services.length), `${board.sections.length} sections`], + ["Up", String(up), "responding"], + ["Down", String(down), down === 0 ? "all green" : "offline"], + ["Widgets", String(widgets.length), "registered"], + ]; + + return ( +
+
+

+ Dash. +

+ + {date} + +
+ +
+ {cells.map(([k, v, s]) => ( +
+
{k}
+
{v}
+
{s}
+
+ ))} +
+ +
{children}
+
+ ); +} + +function SectionView({ section, index, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + +
+ {!section.collapsed && children} +
+ ); +} + +function ItemView({ item, index, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + const state = status?.status; + return ( + + {(open) => ( +
+ + {String(index + 1).padStart(2, "0")} + + + + {item.name} + {host ? {host} : null} + + {host} + + {item.urls.slice(0, 2).map((u) => u.label ? ( + + {u.label} + + ) : null)} + + {item.urls.length > 0 ? ( + + + {state ?? "…"} + + ) : } + → + +
+ )} +
+ ); +} + +function WidgetView({ item, index, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ + {String(index + 1).padStart(2, "0")} + + + {item.name} +
+ +
+ +
+ ); +} + +export const editorial: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "flex flex-col", +}; diff --git a/apps/frontend/src/renderers/index.ts b/apps/frontend/src/renderers/index.ts index fa17959..1a02610 100644 --- a/apps/frontend/src/renderers/index.ts +++ b/apps/frontend/src/renderers/index.ts @@ -1,13 +1,29 @@ import { bento } from "./bento"; import { cards } from "./cards"; +import { editorial } from "./editorial"; +import { terminal } from "./terminal"; import type { RendererViews } from "./types"; import type { RendererName } from "@/types"; // Renderer registry — settings.renderer picks the active one. -export const rendererList = ["bento", "cards"] as const satisfies readonly RendererName[]; -export const renderers: Record = { bento, cards }; +export const rendererList = ["bento", "cards", "index", "console"] as const satisfies readonly RendererName[]; +export const renderers: Record = { + bento, + cards, + index: editorial, + console: terminal, +}; export const rendererLabels: Record = { bento: "Bento", cards: "Cards", + index: "Index", + console: "Console", +}; + +export const rendererDescriptions: Record = { + bento: "mixed-size tile grid", + cards: "uniform compact cards", + index: "editorial list · masthead", + console: "terminal panels · mono", }; diff --git a/apps/frontend/src/renderers/terminal/index.tsx b/apps/frontend/src/renderers/terminal/index.tsx new file mode 100644 index 0000000..51f2a1f --- /dev/null +++ b/apps/frontend/src/renderers/terminal/index.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { useBoard } from "@/board/store"; +import { IconImg, ItemAnchor, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Console — terminal renderer. Mono everything, $ banner, bordered stat +// panels, ## section heads, › prompt rows. Ported from mockups/f-console.html. + +function BoardView({ children }: BoardViewProps) { + const board = useBoard(); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + + const items = board.sections.flatMap((s) => s.items); + const services = items.filter((i) => i.kind !== "widget"); + const widgets = items.filter((i) => i.kind === "widget"); + const endpoints = services.reduce((n, i) => n + i.urls.length, 0); + const up = services.filter((i) => board.statuses[i.id]?.status === "up").length; + const down = services.filter((i) => board.statuses[i.id]?.status === "down").length; + const probed = up + down; + + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }).format(now); + const date = new Intl.DateTimeFormat(undefined, { + weekday: "short", month: "short", day: "numeric", + }).format(now); + + const panels: { head: string; v: string; s: string; live?: boolean; wide?: boolean }[] = [ + { head: "clock --local", v: time, s: date, live: true, wide: true }, + { head: "services --up", v: String(up), s: `of ${probed} probed` }, + { head: "services --down", v: String(down), s: down === 0 ? "all green" : "needs attention" }, + { head: "widgets", v: String(widgets.length), s: "registered" }, + { head: "endpoints", v: String(endpoints), s: "urls indexed" }, + { head: "uptime --probes", v: probed > 0 ? `${Math.round((up / probed) * 100)}%` : "—", s: `${up}/${probed} responding` }, + { head: "sections", v: String(board.sections.length), s: "groups" }, + ]; + + return ( +
+
+ {`$ dash status --all\n`} + {services.length} services + {` indexed · `} + {up} up + {down > 0 ? {` · `}{down} down : ` · all green`} + {` · `} + {widgets.length} widgets + {`\ndrag rows to reorder · click rows with multiple endpoints to choose`} +
+ +
+ {panels.map((p) => ( +
+
+ {p.head} + {p.live ? ● live : null} +
+
+
{p.v}
+
{p.s}
+
+
+ ))} +
+ +
{children}
+
+ ); +} + +function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + +
+ {!section.collapsed && ( +
{children}
+ )} +
+ ); +} + +function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + const state = status?.status; + return ( + + {(open) => ( +
+ › + + + {item.name} + {host ? {host} : null} + + + {host}{item.urls.length > 1 ? ` · ${item.urls.length} endpoints` : ""} + + {item.urls.length > 0 ? ( + + [ {state ?? "…"} ] + + ) : } + +
+ )} +
+ ); +} + +function WidgetView({ item, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ › + + {item.name} +
+ +
+ +
+ ); +} + +export const terminal: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "flex flex-col", +}; diff --git a/apps/frontend/src/renderers/types.ts b/apps/frontend/src/renderers/types.ts index 1916117..c3c720d 100644 --- a/apps/frontend/src/renderers/types.ts +++ b/apps/frontend/src/renderers/types.ts @@ -7,6 +7,7 @@ import type { Item, Section, Status } from "@/types"; export interface SectionViewProps { section: Section; + index: number; onToggle(): void; onDelete(): void; children: ReactNode; // rendered items grid (ItemSortZone output) @@ -14,6 +15,7 @@ export interface SectionViewProps { export interface ItemViewProps { item: Item; + index: number; sectionId: string; status?: Status | undefined; onEdit(): void; diff --git a/apps/frontend/src/types.ts b/apps/frontend/src/types.ts index ae3f113..b07cc50 100644 --- a/apps/frontend/src/types.ts +++ b/apps/frontend/src/types.ts @@ -12,7 +12,7 @@ export type StatusMap = Record; export type ExportPayload = components["schemas"]["Export"]; export type Theme = "dark" | "light"; -export type RendererName = "bento" | "cards"; +export type RendererName = "bento" | "cards" | "index" | "console"; /** Wire shape of GET /api/settings — a free-form map we narrow at the boundary. */ export type SettingsWire = NonNullable< @@ -26,8 +26,9 @@ export interface Prefs { /** Narrow the settings map to typed prefs; unknown/absent keys fall back. */ export function parsePrefs(raw: SettingsWire): Prefs { + const r = raw.renderer; return { theme: raw.theme === "light" ? "light" : "dark", - renderer: raw.renderer === "cards" ? "cards" : "bento", + renderer: r === "cards" || r === "index" || r === "console" ? r : "bento", }; } diff --git a/package-lock.json b/package-lock.json index 51ced51..a3e80b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.5", "@fontsource-variable/geist-mono": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/instrument-serif": "^5.3.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", @@ -890,6 +892,24 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/instrument-serif": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.3.0.tgz", + "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",