mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #7 from Dvorinka/feat/settings-dialog
feat(frontend): regroup settings dialog, icon renderer tiles
This commit is contained in:
6 files changed
+148
-105
No files matched your search
@@ -8,6 +8,10 @@ All notable changes to Dash. Format follows
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Settings dialog regrouped into icon-labelled sections (renderer, appearance,
|
||||||
|
wallpaper/CSS, notifications, auth, backup); renderer tiles now show a
|
||||||
|
per-style glyph and the theme picker is a segmented control
|
||||||
|
|
||||||
- Monitoring charts restyled to the Beszel school: titled chart cards with
|
- Monitoring charts restyled to the Beszel school: titled chart cards with
|
||||||
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
|
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
|
||||||
sorted dotted tooltips, and legend chips on multi-series charts
|
sorted dotted tooltips, and legend chips on multi-series charts
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Bell, Download, Lock, Upload } from "lucide-react";
|
import { Bell, Download, HardDrive, Image, LayoutGrid, Lock, Moon, Palette, Sun, Upload, type LucideIcon } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LOCALES, t } from "@/i18n";
|
import { LOCALES, t } from "@/i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -44,6 +44,19 @@ const TRANSPORTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
type TransportName = keyof typeof TRANSPORTS;
|
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 (
|
||||||
|
<section className="flex flex-col gap-2.5 border-t border-border/60 pt-4 first:border-0 first:pt-0">
|
||||||
|
<h3 className="flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||||
|
<Icon size={11} /> {title}
|
||||||
|
</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -143,82 +156,103 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||||
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
|
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={LayoutGrid} title={t("settings.renderer")}>
|
||||||
<Label>{t("settings.renderer")}</Label>
|
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||||
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
|
{rendererList.map((r) => {
|
||||||
{rendererList.map((r) => (
|
const Icon = rendererIcons[r];
|
||||||
<button
|
const active = board.renderer === r;
|
||||||
key={r}
|
return (
|
||||||
type="button"
|
<button
|
||||||
role="radio"
|
key={r}
|
||||||
aria-checked={board.renderer === r}
|
type="button"
|
||||||
onClick={() => void board.setSetting("renderer", r)}
|
role="radio"
|
||||||
className={cn(
|
aria-checked={active}
|
||||||
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
onClick={() => void board.setSetting("renderer", r)}
|
||||||
board.renderer === r
|
className={cn(
|
||||||
? "border-border-strong bg-surface-hover"
|
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
|
||||||
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
active
|
||||||
)}
|
? "border-accent bg-accent/10"
|
||||||
|
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-[7px] border",
|
||||||
|
active ? "border-accent/40 bg-accent/15 text-accent" : "border-border bg-bg text-text-faint",
|
||||||
|
)}>
|
||||||
|
<Icon size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||||
|
<span className="block truncate font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group icon={Palette} title={t("settings.appearance")}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t("settings.theme")}</Label>
|
||||||
|
<div className="flex overflow-hidden rounded-[7px] border border-border">
|
||||||
|
{(["dark", "light"] as const).map((th) => (
|
||||||
|
<button
|
||||||
|
key={th}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={board.theme === th}
|
||||||
|
onClick={() => void board.setSetting("theme", th)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 text-[12px] transition-colors",
|
||||||
|
board.theme === th ? "bg-surface-hover text-text" : "text-text-faint hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{th === "dark" ? <Moon size={12} /> : <Sun size={12} />}
|
||||||
|
{th === "dark" ? t("settings.dark") : t("settings.light")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t("settings.language")}</Label>
|
||||||
|
<Select
|
||||||
|
value={board.locale || "en"}
|
||||||
|
onValueChange={(v) => void board.setSetting("locale", v)}
|
||||||
>
|
>
|
||||||
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
<SelectContent>
|
||||||
</button>
|
{Object.entries(LOCALES).map(([k, name]) => (
|
||||||
))}
|
<SelectItem key={k} value={k}>{name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Label>{t("settings.theme")}</Label>
|
<input
|
||||||
<Select value={board.theme} onValueChange={(v) => {
|
id="accent" type="color" aria-label={t("settings.accent")}
|
||||||
if (v === "dark" || v === "light") void board.setSetting("theme", v);
|
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
||||||
}}>
|
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
|
||||||
<SelectContent>
|
/>
|
||||||
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
|
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
|
||||||
<SelectItem value="light">{t("settings.light")}</SelectItem>
|
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||||
</SelectContent>
|
{t("common.reset")}
|
||||||
</Select>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t("settings.language")}</Label>
|
|
||||||
<Select
|
|
||||||
value={board.locale || "en"}
|
|
||||||
onValueChange={(v) => void board.setSetting("locale", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(LOCALES).map(([k, name]) => (
|
|
||||||
<SelectItem key={k} value={k}>{name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
id="accent" type="color" aria-label={t("settings.accent")}
|
|
||||||
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
|
|
||||||
onChange={(e) => void board.setSetting("accent", e.target.value)}
|
|
||||||
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
|
||||||
{t("common.reset")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={Image} title={t("settings.wallpaper")}>
|
||||||
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="wallpaper"
|
id="wallpaper"
|
||||||
value={board.wallpaper}
|
value={board.wallpaper}
|
||||||
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
placeholder={t("settings.wallpaperPh")}
|
placeholder={t("settings.wallpaperPh")}
|
||||||
className="font-mono text-[12px]"
|
className="font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="custom-css"
|
id="custom-css"
|
||||||
|
aria-label={t("settings.customCss")}
|
||||||
value={board.customCss}
|
value={board.customCss}
|
||||||
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
||||||
placeholder=".item-tile { border-radius: 0; }"
|
placeholder=".item-tile { border-radius: 0; }"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
|
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Group icon={Bell} title={t("settings.notifications")}>
|
||||||
<Label>{t("settings.notifications")}</Label>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={transport} onValueChange={(v) => {
|
<Select value={transport} onValueChange={(v) => {
|
||||||
// SAFETY: Select only yields TRANSPORTS keys.
|
// SAFETY: Select only yields TRANSPORTS keys.
|
||||||
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
{t("settings.notifyFanout")}
|
{t("settings.notifyFanout")}
|
||||||
</p>
|
</p>
|
||||||
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
|
||||||
</div>
|
</Group>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
|
<Group icon={Lock} title={t("auth.section")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
|
||||||
<Label className="flex items-center gap-1.5">
|
<p className="text-[11px] text-text-faint">
|
||||||
<Lock size={12} /> {t("auth.section")}
|
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
||||||
</Label>
|
</p>
|
||||||
{auth.enabled ? (
|
{auth.enabled ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
||||||
@@ -303,29 +334,28 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-faint">
|
</Group>
|
||||||
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<Group icon={HardDrive} title={t("settings.backup")}>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
<div className="flex gap-2">
|
||||||
<Download size={12} /> {t("settings.exportJson")}
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
</Button>
|
<Download size={12} /> {t("settings.exportJson")}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
</Button>
|
||||||
<Upload size={12} /> {t("settings.import")}
|
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||||
</Button>
|
<Upload size={12} /> {t("settings.import")}
|
||||||
<input
|
</Button>
|
||||||
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
<input
|
||||||
onChange={(e) => {
|
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
|
||||||
const f = e.target.files?.[0];
|
onChange={(e) => {
|
||||||
if (f) void importJson(f);
|
const f = e.target.files?.[0];
|
||||||
e.target.value = "";
|
if (f) void importJson(f);
|
||||||
}}
|
e.target.value = "";
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
</div>
|
||||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||||
|
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { t } from "@/i18n";
|
import { t } from "@/i18n";
|
||||||
|
|
||||||
/** One chart row: epoch ms plus named series values. */
|
/** One chart row: epoch ms plus named series values. */
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const cs: Partial<Record<Key, string>> = {
|
|||||||
"settings.testNotify": "Odeslat testovací notifikaci",
|
"settings.testNotify": "Odeslat testovací notifikaci",
|
||||||
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
"settings.wallpaperPh": "https://… — prázdné = žádná",
|
||||||
"settings.importFailed": "Import selhal",
|
"settings.importFailed": "Import selhal",
|
||||||
|
"settings.backup": "Záloha a import",
|
||||||
|
|
||||||
"service.editTitle": "Upravit službu",
|
"service.editTitle": "Upravit službu",
|
||||||
"service.newTitle": "Přidat službu",
|
"service.newTitle": "Přidat službu",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const en = {
|
|||||||
"settings.testNotify": "Send test notification",
|
"settings.testNotify": "Send test notification",
|
||||||
"settings.wallpaperPh": "https://… — blank for none",
|
"settings.wallpaperPh": "https://… — blank for none",
|
||||||
"settings.importFailed": "Import failed",
|
"settings.importFailed": "Import failed",
|
||||||
|
"settings.backup": "Backup & import",
|
||||||
|
|
||||||
"service.editTitle": "Edit service",
|
"service.editTitle": "Edit service",
|
||||||
"service.newTitle": "Add service",
|
"service.newTitle": "Add service",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
|
||||||
import { bento } from "./bento";
|
import { bento } from "./bento";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
import { editorial } from "./editorial";
|
import { editorial } from "./editorial";
|
||||||
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
|
|||||||
index: "editorial list · masthead",
|
index: "editorial list · masthead",
|
||||||
console: "terminal panels · mono",
|
console: "terminal panels · mono",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rendererIcons: Record<RendererName, LucideIcon> = {
|
||||||
|
bento: LayoutGrid,
|
||||||
|
cards: LayoutList,
|
||||||
|
index: Newspaper,
|
||||||
|
console: SquareTerminal,
|
||||||
|
};
|
||||||
Reference in new issue
Block a user