mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat(frontend): regroup settings dialog, icon renderer tiles
Settings splits into labelled sections — renderer, appearance, wallpaper and CSS, notifications, auth, backup — each with an icon header. Renderer options gain a per-style glyph and accent-highlighted selection; theme becomes a segmented Dark/Light control; the dialog widens to max-w-xl. Renderer switching itself verified working end-to-end (live switch + persistence across reload); unchanged code path since v2.0.0. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5473881f93
commit
c5339a24b4
6 files changed
+93
-50
No files matched your search
@@ -8,6 +8,10 @@ All notable changes to Dash. Format follows
|
||||
|
||||
### 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
|
||||
subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks,
|
||||
sorted dotted tooltips, and legend chips on multi-series charts
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { api } from "@/api";
|
||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
||||
import { rendererDescriptions, rendererIcons, rendererLabels, rendererList } from "@/renderers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LOCALES, t } from "@/i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -44,6 +44,19 @@ const TRANSPORTS = {
|
||||
} 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 (
|
||||
<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 }) {
|
||||
const board = useBoard();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -143,50 +156,70 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
<DialogDescription>{t("settings.desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("settings.renderer")}</Label>
|
||||
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||
{rendererList.map((r) => (
|
||||
<div className="flex max-h-[72vh] flex-col gap-5 overflow-y-auto pr-1">
|
||||
<Group icon={LayoutGrid} title={t("settings.renderer")}>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label={t("settings.renderer")}>
|
||||
{rendererList.map((r) => {
|
||||
const Icon = rendererIcons[r];
|
||||
const active = board.renderer === r;
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={board.renderer === r}
|
||||
aria-checked={active}
|
||||
onClick={() => void board.setSetting("renderer", r)}
|
||||
className={cn(
|
||||
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
||||
board.renderer === r
|
||||
? "border-border-strong bg-surface-hover"
|
||||
"flex items-center gap-3 rounded-[9px] border px-3 py-2.5 text-left transition-colors",
|
||||
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 font-mono text-[10px] text-text-faint">{rendererDescriptions[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.theme")}</Label>
|
||||
<Select value={board.theme} onValueChange={(v) => {
|
||||
if (v === "dark" || v === "light") void board.setSetting("theme", v);
|
||||
}}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
|
||||
<SelectItem value="light">{t("settings.light")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("settings.language")}</Label>
|
||||
<Select
|
||||
@@ -201,24 +234,25 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="accent">{t("settings.accent")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center 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"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-text-faint">{board.accent || "—"}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
|
||||
<Group icon={Image} title={t("settings.wallpaper")}>
|
||||
<Input
|
||||
id="wallpaper"
|
||||
value={board.wallpaper}
|
||||
@@ -226,22 +260,19 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
placeholder={t("settings.wallpaperPh")}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
|
||||
<textarea
|
||||
id="custom-css"
|
||||
aria-label={t("settings.customCss")}
|
||||
value={board.customCss}
|
||||
onChange={(e) => void board.setSetting("customCss", e.target.value)}
|
||||
placeholder=".item-tile { border-radius: 0; }"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">{t("settings.customCssHint")}</p>
|
||||
</Group>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("settings.notifications")}</Label>
|
||||
<Group icon={Bell} title={t("settings.notifications")}>
|
||||
<div className="flex gap-2">
|
||||
<Select value={transport} onValueChange={(v) => {
|
||||
// SAFETY: Select only yields TRANSPORTS keys.
|
||||
@@ -281,13 +312,13 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
{t("settings.notifyFanout")}
|
||||
</p>
|
||||
{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">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<Lock size={12} /> {t("auth.section")}
|
||||
</Label>
|
||||
<Group icon={Lock} title={t("auth.section")}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
|
||||
<p className="text-[11px] text-text-faint">
|
||||
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
||||
</p>
|
||||
{auth.enabled ? (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
|
||||
@@ -303,12 +334,10 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">
|
||||
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
|
||||
</p>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Group icon={HardDrive} title={t("settings.backup")}>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||
<Download size={12} /> {t("settings.exportJson")}
|
||||
</Button>
|
||||
@@ -326,6 +355,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
|
||||
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||
</Group>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
/** 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.wallpaperPh": "https://… — prázdné = žádná",
|
||||
"settings.importFailed": "Import selhal",
|
||||
"settings.backup": "Záloha a import",
|
||||
|
||||
"service.editTitle": "Upravit službu",
|
||||
"service.newTitle": "Přidat službu",
|
||||
|
||||
@@ -108,6 +108,7 @@ export const en = {
|
||||
"settings.testNotify": "Send test notification",
|
||||
"settings.wallpaperPh": "https://… — blank for none",
|
||||
"settings.importFailed": "Import failed",
|
||||
"settings.backup": "Backup & import",
|
||||
|
||||
"service.editTitle": "Edit service",
|
||||
"service.newTitle": "Add service",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LayoutGrid, LayoutList, Newspaper, SquareTerminal, type LucideIcon } from "lucide-react";
|
||||
import { bento } from "./bento";
|
||||
import { cards } from "./cards";
|
||||
import { editorial } from "./editorial";
|
||||
@@ -27,3 +28,10 @@ export const rendererDescriptions: Record<RendererName, string> = {
|
||||
index: "editorial list · masthead",
|
||||
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