import { useEffect, useRef, useState } from "react"; import { Bell, Download, HardDrive, Image, LayoutGrid, Lock, Moon, Palette, Sun, Upload, type LucideIcon } from "lucide-react"; import { useBoard } from "@/board/store"; import { api } from "@/api"; import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers"; import { cn } from "@/lib/utils"; import { LOCALES, t } from "@/i18n"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // Notification transports — mirrors the backend registry. Config is stored // under notify_ as a JSON object; legacy notify_webhook strings map to // {url}. const TRANSPORTS = { webhook: { label: "Webhook", fields: [{ k: "url", l: "URL", ph: "https://…" }] }, slack: { label: "Slack", fields: [{ k: "url", l: "Webhook URL", ph: "https://hooks.slack.com/…" }] }, discord: { label: "Discord", fields: [{ k: "url", l: "Webhook URL", ph: "https://discord.com/api/webhooks/…" }] }, telegram: { label: "Telegram", fields: [ { k: "token", l: "Bot token", ph: "123456:ABC…", secret: true }, { k: "chat_id", l: "Chat ID", ph: "-100…" }, { k: "server", l: "API server (optional)", ph: "https://api.telegram.org" }, ] }, gotify: { label: "Gotify", fields: [ { k: "server", l: "Server", ph: "https://gotify.local" }, { k: "token", l: "App token", secret: true }, ] }, ntfy: { label: "ntfy", fields: [ { k: "server", l: "Server", ph: "https://ntfy.sh" }, { k: "topic", l: "Topic", ph: "dash-alerts" }, ] }, smtp: { label: "SMTP", fields: [ { k: "host", l: "Host", ph: "smtp.local" }, { k: "port", l: "Port", ph: "587" }, { k: "user", l: "User (optional)" }, { k: "pass", l: "Password", secret: true }, { k: "from", l: "From", ph: "dash@local" }, { k: "to", l: "To", ph: "me@local, oncall@local" }, ] }, } as const; type TransportName = keyof typeof TRANSPORTS; // Group — a labelled settings section. Icon + mono header keeps the dialog // scannable now that it holds five distinct concerns. function Group({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: React.ReactNode }) { return (

{title}

{children}
); } export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { const board = useBoard(); const fileRef = useRef(null); const [msg, setMsg] = useState(""); const [transport, setTransport] = useState("webhook"); const [notifyCfg, setNotifyCfg] = useState>>({}); const [notifyMsg, setNotifyMsg] = useState(""); const [auth, setAuth] = useState<{ enabled?: boolean; authed?: boolean; username?: string }>({}); useEffect(() => { if (!open) return; void api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {})); void api.GET("/api/settings").then(({ data }) => { const cfg: Record> = {}; for (const name of Object.keys(TRANSPORTS)) { const v = data?.["notify_" + name]; if (typeof v === "string") cfg[name] = { url: v }; // legacy webhook else if (v !== null && typeof v === "object" && !Array.isArray(v)) { const obj: Record = {}; for (const [k, val] of Object.entries(v)) if (typeof val === "string") obj[k] = val; cfg[name] = obj; } } setNotifyCfg(cfg); setNotifyMsg(""); }); }, [open]); const fields = TRANSPORTS[transport].fields; const cfg = notifyCfg[transport] ?? {}; const setField = (k: string, v: string) => { setNotifyCfg((s) => ({ ...s, [transport]: { ...s[transport], [k]: v } })); setNotifyMsg(""); }; async function saveNotify() { setNotifyMsg(""); await api.PUT("/api/settings", { body: { ["notify_" + transport]: cfg } }); setNotifyMsg(t("settings.saved")); } async function testNotify() { setNotifyMsg(""); const { data, error } = await api.POST("/api/notify/test", { body: { transport } }); const r = data?.results?.[transport]; setNotifyMsg(error ? t("settings.failed") : r === "ok" ? t("settings.sent") : (r ?? t("settings.sent"))); } // Enabling auth flips the whole app to the setup screen — reload so the // gate picks it up. Same on sign-out/disable. async function enableAuth() { await api.PUT("/api/settings", { body: { auth_enabled: true } }); location.reload(); } async function signOut() { await api.POST("/api/auth/logout"); location.reload(); } async function disableAuth() { await api.POST("/api/auth/disable"); location.reload(); } async function exportJson() { const res = await fetch("/api/export"); if (!res.ok) return; const blob = await res.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "dash-export.json"; a.click(); URL.revokeObjectURL(a.href); } async function importJson(file: File) { setMsg(""); // Dash exports are JSON with {version, sections}; anything else // (Homepage/Homarr/Dashy) goes to the external importer for sniffing. let target = "/api/import/external"; try { const j: unknown = JSON.parse(await file.text()); if (j !== null && typeof j === "object" && "version" in j && "sections" in j) { target = "/api/import"; } } catch { /* not JSON — external importer handles YAML */ } const res = await fetch(target, { method: "POST", body: file }); if (res.ok) { location.reload(); // import replaces state; a reload re-syncs everything } else { // SAFETY: error responses always carry {error: string} per openapi.yaml Error schema. const body = (await res.json().catch(() => null)) as { error?: string } | null; setMsg(body?.error ?? t("settings.importFailed")); } } return ( {t("settings.title")} {t("settings.desc")}
{rendererList.map((r) => { const Icon = rendererIcons[r]; const active = board.renderer === r; return ( ); })}
{(["dark", "light"] as const).map((th) => ( ))}
void board.setSetting("accent", e.target.value)} className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5" /> {board.accent || "—"}
void board.setSetting("wallpaper", e.target.value)} placeholder={t("settings.wallpaperPh")} className="font-mono text-[12px]" />