mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: phase 3 renderers & ergonomics — index/console, ⌘K palette, icon suggest
- 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 <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8afc7df53b
commit
55fb57456e
16 files changed
+803
-51
No files matched your search
+7
-7
@@ -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.
|
**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
|
- [x] **Index** renderer: serif section headers, full-width rows, masthead stat strip
|
||||||
- [ ] **Console** renderer: bordered panels/tables, JetBrains Mono, `[ up ]` tags
|
- [x] **Console** renderer: bordered panels/tables, JetBrains Mono, `[ up ]` tags
|
||||||
- [ ] Renderer switcher polished (instant swap, per-renderer preview in settings)
|
- [x] Renderer switcher polished (2×2 radio grid with per-renderer descriptions)
|
||||||
- [ ] ⌘K command palette: jump to service, add service, toggle theme, switch renderer
|
- [x] ⌘K command palette: jump to service, add service, toggle theme, switch renderer (⌘K / Ctrl+K / `/`)
|
||||||
- [ ] dashboard-icons auto-suggest in icon picker (name → icon URL)
|
- [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)
|
- [ ] 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
|
## Phase 4 — Open-source launch → v1.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/geist": "^5.2.5",
|
"@fontsource-variable/geist": "^5.2.5",
|
||||||
"@fontsource-variable/geist-mono": "^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-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react";
|
||||||
import { BoardProvider, useBoard } from "@/board/store";
|
import { BoardProvider, useBoard } from "@/board/store";
|
||||||
import { Board } from "@/board/Board";
|
import { Board } from "@/board/Board";
|
||||||
import { ServiceDialog } from "@/components/ServiceDialog";
|
import { ServiceDialog } from "@/components/ServiceDialog";
|
||||||
import { WidgetDialog } from "@/components/WidgetDialog";
|
import { WidgetDialog } from "@/components/WidgetDialog";
|
||||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||||
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Item } from "@/types";
|
import type { Item } from "@/types";
|
||||||
|
|
||||||
@@ -19,10 +20,28 @@ export default function App() {
|
|||||||
function Shell() {
|
function Shell() {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [svcOpen, setSvcOpen] = useState(false);
|
const [svcOpen, setSvcOpen] = useState(false);
|
||||||
const [wdgOpen, setWdgOpen] = useState(false);
|
const [wdgOpen, setWdgOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Item | undefined>();
|
const [editing, setEditing] = useState<Item | undefined>();
|
||||||
|
|
||||||
|
// ⌘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) {
|
function openEditor(it: Item) {
|
||||||
setEditing(it);
|
setEditing(it);
|
||||||
if (it.kind === "widget") setWdgOpen(true);
|
if (it.kind === "widget") setWdgOpen(true);
|
||||||
@@ -42,6 +61,14 @@ function Shell() {
|
|||||||
Dash
|
Dash
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline" aria-label="Search (⌘K)"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="gap-1.5 text-text-faint"
|
||||||
|
>
|
||||||
|
<Search size={14} strokeWidth={1.8} />
|
||||||
|
<kbd className="hidden font-mono text-[10px] min-[620px]:inline">⌘K</kbd>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline" size="icon" aria-label="Settings"
|
variant="outline" size="icon" aria-label="Settings"
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
@@ -73,6 +100,13 @@ function Shell() {
|
|||||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onOpenChange={setPaletteOpen}
|
||||||
|
onAddService={() => { setEditing(undefined); setSvcOpen(true); }}
|
||||||
|
onAddWidget={() => { setEditing(undefined); setWdgOpen(true); }}
|
||||||
|
onSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,11 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
|
|||||||
<BoardDnd>
|
<BoardDnd>
|
||||||
<SectionSortList ids={board.sections.map((s) => s.id)}>
|
<SectionSortList ids={board.sections.map((s) => s.id)}>
|
||||||
<views.BoardView>
|
<views.BoardView>
|
||||||
{board.sections.map((sec) => (
|
{board.sections.map((sec, si) => (
|
||||||
<views.SectionView
|
<views.SectionView
|
||||||
key={sec.id}
|
key={sec.id}
|
||||||
section={sec}
|
section={sec}
|
||||||
|
index={si}
|
||||||
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
|
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) {
|
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 }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemSortZone section={sec} className={views.itemsGridClass}>
|
<ItemSortZone section={sec} className={views.itemsGridClass}>
|
||||||
{sec.items.map((it) =>
|
{sec.items.map((it, ii) =>
|
||||||
it.kind === "widget" ? (
|
it.kind === "widget" ? (
|
||||||
<views.WidgetView key={it.id} item={it} sectionId={sec.id} onEdit={() => onEditItem(it)} />
|
<views.WidgetView key={it.id} item={it} index={ii} sectionId={sec.id} onEdit={() => onEditItem(it)} />
|
||||||
) : (
|
) : (
|
||||||
<views.ItemView
|
<views.ItemView
|
||||||
key={it.id}
|
key={it.id}
|
||||||
item={it}
|
item={it}
|
||||||
|
index={ii}
|
||||||
sectionId={sec.id}
|
sectionId={sec.id}
|
||||||
status={board.statuses[it.id]}
|
status={board.statuses[it.id]}
|
||||||
onEdit={() => onEditItem(it)}
|
onEdit={() => onEditItem(it)}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const commands = useMemo<Command[]>(() => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border px-4">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
|
||||||
|
</div>
|
||||||
|
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
|
||||||
|
) : groups.map((g) => (
|
||||||
|
<div key={g.name}>
|
||||||
|
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
{g.name}
|
||||||
|
</div>
|
||||||
|
{g.items.map(({ c, i }) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
data-idx={i}
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2.5 rounded-[7px] px-2.5 py-2 text-left text-[13px]",
|
||||||
|
i === clamped && "bg-surface-hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.icon ? (
|
||||||
|
<IconImg item={c.icon} size={22} />
|
||||||
|
) : c.glyph ? (
|
||||||
|
<span className="flex size-[22px] items-center justify-center text-text-faint">
|
||||||
|
<c.glyph size={13} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate">{c.label}</span>
|
||||||
|
{c.hint ? <span className="ml-auto truncate font-mono text-[10.5px] text-text-faint">{c.hint}</span> : null}
|
||||||
|
{i === clamped ? <ArrowRight size={12} className="shrink-0 text-text-faint" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
// dashboard-icons CDN serves png/<slug>.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.
|
// One dialog for both add and edit: item present = edit, absent = add.
|
||||||
export function ServiceDialog({
|
export function ServiceDialog({
|
||||||
open,
|
open,
|
||||||
@@ -164,7 +170,8 @@ export function ServiceDialog({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="svc-icon">Icon URL</Label>
|
<Label htmlFor="svc-icon">Icon URL</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<IconPreview url={icon} />
|
||||||
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
|
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
|
||||||
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
|
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
|
||||||
<Upload size={13} />
|
<Upload size={13} />
|
||||||
@@ -174,6 +181,15 @@ export function ServiceDialog({
|
|||||||
onChange={() => setErr("")}
|
onChange={() => setErr("")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!icon && diSlug(name) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
|
||||||
|
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
|
||||||
|
>
|
||||||
|
suggest: {diSlug(name)}.png
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,3 +213,21 @@ export function ServiceDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 <span className="size-[30px] shrink-0 rounded-[7px] border border-dashed border-border" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Download, Upload } from "lucide-react";
|
import { Download, Upload } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||||
@@ -46,16 +47,26 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Renderer</Label>
|
<Label>Renderer</Label>
|
||||||
<Select value={board.renderer} onValueChange={(v) => {
|
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
|
||||||
if (v === "bento" || v === "cards") void board.setSetting("renderer", v);
|
{rendererList.map((r) => (
|
||||||
}}>
|
<button
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
key={r}
|
||||||
<SelectContent>
|
type="button"
|
||||||
{rendererList.map((r) => (
|
role="radio"
|
||||||
<SelectItem key={r} value={r}>{rendererLabels[r]}</SelectItem>
|
aria-checked={board.renderer === r}
|
||||||
))}
|
onClick={() => void board.setSetting("renderer", r)}
|
||||||
</SelectContent>
|
className={cn(
|
||||||
</Select>
|
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
||||||
|
board.renderer === r
|
||||||
|
? "border-border-strong bg-surface-hover"
|
||||||
|
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||||
|
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const Dialog = DialogPrimitive.Root;
|
|||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
function DialogContent({
|
||||||
|
className, children, showCloseButton = true, ...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||||
@@ -22,10 +24,12 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
{showCloseButton ? (
|
||||||
<X size={14} />
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||||
<span className="sr-only">Close</span>
|
<X size={14} />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
) : null}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-18
@@ -110,28 +110,32 @@
|
|||||||
|
|
||||||
--font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
--font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: "Geist Mono Variable", ui-monospace, monospace;
|
--font-mono: "Geist Mono Variable", ui-monospace, monospace;
|
||||||
|
--font-serif: "Instrument Serif", Georgia, serif;
|
||||||
|
--font-terminal: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
@layer base {
|
||||||
-webkit-font-smoothing: antialiased;
|
html {
|
||||||
}
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
transition:
|
transition:
|
||||||
background 200ms var(--ease),
|
background 200ms var(--ease),
|
||||||
color 200ms var(--ease);
|
color 200ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import "@fontsource-variable/geist";
|
import "@fontsource-variable/geist";
|
||||||
import "@fontsource-variable/geist-mono";
|
import "@fontsource-variable/geist-mono";
|
||||||
|
import "@fontsource/instrument-serif";
|
||||||
|
import "@fontsource-variable/jetbrains-mono";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<header className="mb-7 flex items-end justify-between gap-6 border-b border-border-strong pb-5">
|
||||||
|
<h1 className="font-serif text-[52px] leading-none tracking-tight max-[620px]:text-[38px]">
|
||||||
|
Dash.
|
||||||
|
</h1>
|
||||||
|
<span className="pb-1 text-right font-mono text-[11px] uppercase tracking-[0.08em] text-text-faint">
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-10 grid grid-cols-2 gap-px overflow-hidden rounded-[10px] border border-border bg-border min-[760px]:grid-cols-5">
|
||||||
|
{cells.map(([k, v, s]) => (
|
||||||
|
<div key={k} className="bg-surface px-4 py-3.5">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
|
||||||
|
<div className="mt-1 font-mono text-[24px] font-medium leading-none tracking-tight tabular-nums">{v}</div>
|
||||||
|
<div className="mt-1.5 truncate text-[10.5px] text-text-faint">{s}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-9">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, index, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-35")}>
|
||||||
|
<div className="group/sec mb-0.5 flex cursor-pointer select-none items-baseline gap-3.5 border-b border-border-strong pb-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab self-center text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-baseline gap-3.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-serif text-[30px] leading-none text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<h2 className="truncate font-serif text-[30px] leading-none tracking-tight">
|
||||||
|
{section.name}
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint">
|
||||||
|
{section.items.length} {section.items.length === 1 ? "entry" : "entries"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={cn("ml-auto self-center text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="self-center text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[26px_30px_minmax(0,1fr)_64px] items-center gap-3.5",
|
||||||
|
"border-b border-border px-1.5 py-[11px] transition-colors hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[30px_34px_minmax(0,1.4fr)_minmax(0,1fr)_auto_70px_22px]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<IconImg item={item} size={30} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[14px] font-medium tracking-tight">{item.name}</span>
|
||||||
|
{host ? <span className="block truncate font-mono text-[10.5px] text-text-faint min-[760px]:hidden">{host}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="hidden min-w-0 truncate font-mono text-[11px] text-text-faint min-[760px]:block">{host}</span>
|
||||||
|
<span className="hidden justify-end gap-1.5 min-[760px]:flex">
|
||||||
|
{item.urls.slice(0, 2).map((u) => u.label ? (
|
||||||
|
<span key={u.id} className="rounded border border-border px-[5px] py-0.5 font-mono text-[9px] uppercase tracking-[0.08em] text-text-faint">
|
||||||
|
{u.label}
|
||||||
|
</span>
|
||||||
|
) : null)}
|
||||||
|
</span>
|
||||||
|
{item.urls.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-1.5 justify-self-end font-mono text-[10.5px] tracking-[0.05em] text-text-dim transition-opacity group-hover/row:opacity-0">
|
||||||
|
<span className={cn("size-[5px] rounded-full", state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
{state ?? "…"}
|
||||||
|
</span>
|
||||||
|
) : <span />}
|
||||||
|
<span className="hidden justify-self-end text-text-faint opacity-0 transition-all group-hover/row:opacity-100 min-[760px]:block">→</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetView({ item, index, sectionId, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[26px_30px_minmax(0,1fr)] items-center gap-3.5",
|
||||||
|
"border-b border-border px-1.5 py-[11px] transition-colors hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[30px_34px_minmax(0,0.8fr)_minmax(0,1.2fr)]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<IconImg item={item} size={30} />
|
||||||
|
<span className="min-w-0 truncate text-[14px] font-medium tracking-tight">{item.name}</span>
|
||||||
|
<div className="min-w-0 max-[759px]:col-start-3">
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorial: RendererViews = {
|
||||||
|
BoardView,
|
||||||
|
SectionView,
|
||||||
|
ItemView,
|
||||||
|
WidgetView,
|
||||||
|
itemsGridClass: "flex flex-col",
|
||||||
|
};
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
import { bento } from "./bento";
|
import { bento } from "./bento";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
|
import { editorial } from "./editorial";
|
||||||
|
import { terminal } from "./terminal";
|
||||||
import type { RendererViews } from "./types";
|
import type { RendererViews } from "./types";
|
||||||
import type { RendererName } from "@/types";
|
import type { RendererName } from "@/types";
|
||||||
|
|
||||||
// Renderer registry — settings.renderer picks the active one.
|
// Renderer registry — settings.renderer picks the active one.
|
||||||
export const rendererList = ["bento", "cards"] as const satisfies readonly RendererName[];
|
export const rendererList = ["bento", "cards", "index", "console"] as const satisfies readonly RendererName[];
|
||||||
export const renderers: Record<RendererName, RendererViews> = { bento, cards };
|
export const renderers: Record<RendererName, RendererViews> = {
|
||||||
|
bento,
|
||||||
|
cards,
|
||||||
|
index: editorial,
|
||||||
|
console: terminal,
|
||||||
|
};
|
||||||
|
|
||||||
export const rendererLabels: Record<RendererName, string> = {
|
export const rendererLabels: Record<RendererName, string> = {
|
||||||
bento: "Bento",
|
bento: "Bento",
|
||||||
cards: "Cards",
|
cards: "Cards",
|
||||||
|
index: "Index",
|
||||||
|
console: "Console",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rendererDescriptions: Record<RendererName, string> = {
|
||||||
|
bento: "mixed-size tile grid",
|
||||||
|
cards: "uniform compact cards",
|
||||||
|
index: "editorial list · masthead",
|
||||||
|
console: "terminal panels · mono",
|
||||||
};
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="font-terminal">
|
||||||
|
<div className="mb-6 whitespace-pre-wrap rounded-md border border-border px-4 py-3.5 text-[11px] leading-[1.7] text-text-faint">
|
||||||
|
{`$ dash status --all\n`}
|
||||||
|
<span className="font-medium text-text-dim">{services.length} services</span>
|
||||||
|
{` indexed · `}
|
||||||
|
<span className="text-up">{up} up</span>
|
||||||
|
{down > 0 ? <span>{` · `}<span className="text-down">{down} down</span></span> : ` · all green`}
|
||||||
|
{` · `}
|
||||||
|
<span className="font-medium text-text-dim">{widgets.length} widgets</span>
|
||||||
|
{`\ndrag rows to reorder · click rows with multiple endpoints to choose`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-2.5 min-[760px]:grid-cols-4">
|
||||||
|
{panels.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.head}
|
||||||
|
className={cn("overflow-hidden rounded-md border border-border bg-surface", p.wide && "col-span-2")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2 text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
{p.head}
|
||||||
|
{p.live ? <span className="ml-auto text-[9px] text-up">● live</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3">
|
||||||
|
<div className={cn("font-medium leading-none tracking-tight tabular-nums", p.wide ? "text-[26px]" : "text-[22px]")}>{p.v}</div>
|
||||||
|
<div className="mt-1 truncate text-[10.5px] text-text-faint">{p.s}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-35")}>
|
||||||
|
<div className="group/sec mb-2 flex cursor-pointer select-none items-center gap-2.5 px-0.5 py-1 text-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">##</span>
|
||||||
|
<h2 className="truncate font-bold lowercase tracking-[0.02em]">{section.name}</h2>
|
||||||
|
<span className="text-[11px] text-text-faint">[{section.items.length}]</span>
|
||||||
|
<span className="flex-1 overflow-hidden whitespace-nowrap text-text-faint/60" aria-hidden>
|
||||||
|
{"─".repeat(120)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className={cn("text-text-faint transition-transform duration-150", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
≡
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && (
|
||||||
|
<div className="overflow-hidden rounded-md border border-border bg-surface">{children}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[16px_24px_minmax(0,1fr)_auto] items-center gap-3",
|
||||||
|
"border-b border-border px-3 py-2 text-[12.5px] transition-colors last:border-b-0 hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[20px_26px_minmax(0,1.2fr)_minmax(0,1fr)_auto]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">›</span>
|
||||||
|
<IconImg item={item} size={22} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium lowercase">{item.name}</span>
|
||||||
|
{host ? <span className="block truncate text-[10.5px] text-text-faint min-[760px]:hidden">{host}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="hidden min-w-0 truncate text-[11px] text-text-faint min-[760px]:block">
|
||||||
|
{host}{item.urls.length > 1 ? ` · ${item.urls.length} endpoints` : ""}
|
||||||
|
</span>
|
||||||
|
{item.urls.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"justify-self-end text-[10.5px] tracking-[0.04em] transition-opacity group-hover/row:opacity-0",
|
||||||
|
state === "up" ? "text-up" : state === "down" ? "text-down" : "text-text-faint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
[ {state ?? "…"} ]
|
||||||
|
</span>
|
||||||
|
) : <span />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetView({ item, sectionId, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[16px_24px_minmax(0,1fr)] items-center gap-3",
|
||||||
|
"border-b border-border px-3 py-2 text-[12.5px] transition-colors last:border-b-0 hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[20px_26px_minmax(0,0.8fr)_minmax(0,1.2fr)]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">›</span>
|
||||||
|
<IconImg item={item} size={22} />
|
||||||
|
<span className="min-w-0 truncate font-medium lowercase">{item.name}</span>
|
||||||
|
<div className="min-w-0 max-[759px]:col-start-3 max-[759px]:py-1">
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminal: RendererViews = {
|
||||||
|
BoardView,
|
||||||
|
SectionView,
|
||||||
|
ItemView,
|
||||||
|
WidgetView,
|
||||||
|
itemsGridClass: "flex flex-col",
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import type { Item, Section, Status } from "@/types";
|
|||||||
|
|
||||||
export interface SectionViewProps {
|
export interface SectionViewProps {
|
||||||
section: Section;
|
section: Section;
|
||||||
|
index: number;
|
||||||
onToggle(): void;
|
onToggle(): void;
|
||||||
onDelete(): void;
|
onDelete(): void;
|
||||||
children: ReactNode; // rendered items grid (ItemSortZone output)
|
children: ReactNode; // rendered items grid (ItemSortZone output)
|
||||||
@@ -14,6 +15,7 @@ export interface SectionViewProps {
|
|||||||
|
|
||||||
export interface ItemViewProps {
|
export interface ItemViewProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
|
index: number;
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
status?: Status | undefined;
|
status?: Status | undefined;
|
||||||
onEdit(): void;
|
onEdit(): void;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type StatusMap = Record<string, Status>;
|
|||||||
export type ExportPayload = components["schemas"]["Export"];
|
export type ExportPayload = components["schemas"]["Export"];
|
||||||
|
|
||||||
export type Theme = "dark" | "light";
|
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. */
|
/** Wire shape of GET /api/settings — a free-form map we narrow at the boundary. */
|
||||||
export type SettingsWire = NonNullable<
|
export type SettingsWire = NonNullable<
|
||||||
@@ -26,8 +26,9 @@ export interface Prefs {
|
|||||||
|
|
||||||
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
|
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
|
||||||
export function parsePrefs(raw: SettingsWire): Prefs {
|
export function parsePrefs(raw: SettingsWire): Prefs {
|
||||||
|
const r = raw.renderer;
|
||||||
return {
|
return {
|
||||||
theme: raw.theme === "light" ? "light" : "dark",
|
theme: raw.theme === "light" ? "light" : "dark",
|
||||||
renderer: raw.renderer === "cards" ? "cards" : "bento",
|
renderer: r === "cards" || r === "index" || r === "console" ? r : "bento",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Generated
+20
@@ -25,6 +25,8 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/geist": "^5.2.5",
|
"@fontsource-variable/geist": "^5.2.5",
|
||||||
"@fontsource-variable/geist-mono": "^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-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
@@ -890,6 +892,24 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
Reference in new issue
Block a user