mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 08:04:58 +00:00
feat(ui): phase 5 — graphs, widgets, theming, PWA, boards, i18n
P5a: 24h uptime bar strip + latency chart on monitor detail, iframe
embed widget, generic JSON-path widget, accent/wallpaper/custom CSS
settings, PWA manifest + service worker, demo GIF.
P5b: transport picker + per-transport fields + test button in
settings; per-monitor alert-rule fields in dialogs.
P5c: container cpu/mem columns, SMART/ZFS/GPU sections on system
detail, subdomains section on domain detail.
P5d: login/setup page + auth middleware client handling, board
switcher + /b/:slug routes, full string extraction to typed t() keys
with {param} interpolation, complete cs locale, language select.
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
f3143af351
commit
e0ef60eca6
49 files changed
+2131
-426
No files matched your search
@@ -9,20 +9,33 @@ import {
|
||||
} from "react";
|
||||
import { api } from "@/api";
|
||||
import type {
|
||||
Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
|
||||
Board, Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
|
||||
} from "@/types";
|
||||
import { parsePrefs } from "@/types";
|
||||
import { setLocale } from "@/i18n";
|
||||
|
||||
// Board store — single renderer-agnostic source of truth for the board tree,
|
||||
// reachability statuses, and persisted settings. Renderers consume via useBoard.
|
||||
|
||||
interface BoardState {
|
||||
sections: Section[];
|
||||
boards: Board[];
|
||||
boardId: string;
|
||||
boardSlug: string;
|
||||
statuses: StatusMap;
|
||||
theme: Theme;
|
||||
renderer: RendererName;
|
||||
accent: string;
|
||||
wallpaper: string;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
loaded: boolean;
|
||||
refresh(): Promise<void>;
|
||||
// slug is the URL form; "" means the default (first) board.
|
||||
setActiveSlug(slug: string): void;
|
||||
addBoard(name: string): Promise<Board | null>;
|
||||
renameBoard(id: string, name: string): Promise<void>;
|
||||
deleteBoard(id: string): Promise<void>;
|
||||
addSection(name: string): Promise<Section | null>;
|
||||
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
|
||||
deleteSection(id: string): Promise<void>;
|
||||
@@ -41,14 +54,22 @@ const Ctx = createContext<BoardState | null>(null);
|
||||
|
||||
export function BoardProvider({ children }: { children: ReactNode }) {
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [activeSlug, setActiveSlug] = useState("");
|
||||
const [statuses, setStatuses] = useState<StatusMap>({});
|
||||
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento" });
|
||||
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento", accent: "", wallpaper: "", customCss: "", locale: "" });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Resolve slug → id; unknown/empty slugs fall back to the first board.
|
||||
const board = boards.find((b) => b.slug === activeSlug) ?? boards[0];
|
||||
const boardId = board?.id ?? "";
|
||||
const boardSlug = board?.slug ?? "";
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const { data } = await api.GET("/api/sections");
|
||||
if (!boardId) return;
|
||||
const { data } = await api.GET("/api/sections", { params: { query: { board: boardId } } });
|
||||
if (data) setSections(data);
|
||||
}, []);
|
||||
}, [boardId]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const { data } = await api.GET("/api/status");
|
||||
@@ -57,11 +78,11 @@ export function BoardProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const [{ data: secs }, { data: cfg }] = await Promise.all([
|
||||
api.GET("/api/sections"),
|
||||
const [{ data: bs }, { data: cfg }] = await Promise.all([
|
||||
api.GET("/api/boards"),
|
||||
api.GET("/api/settings"),
|
||||
]);
|
||||
if (secs) setSections(secs);
|
||||
if (bs) setBoards(bs);
|
||||
if (cfg) setPrefs(parsePrefs(cfg));
|
||||
setLoaded(true);
|
||||
void refreshStatus();
|
||||
@@ -70,7 +91,34 @@ export function BoardProvider({ children }: { children: ReactNode }) {
|
||||
return () => clearInterval(t);
|
||||
}, [refreshStatus]);
|
||||
|
||||
const { theme, renderer } = prefs;
|
||||
// Sections reload whenever the active board changes.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const addBoard = useCallback(async (name: string) => {
|
||||
const { data } = await api.POST("/api/boards", { body: { name } });
|
||||
if (data) setBoards((bs) => [...bs, data]);
|
||||
return data ?? null;
|
||||
}, []);
|
||||
|
||||
const renameBoard = useCallback(async (id: string, name: string) => {
|
||||
const { data } = await api.PATCH("/api/boards/{id}", { params: { path: { id } }, body: { name } });
|
||||
if (data) setBoards((bs) => bs.map((b) => (b.id === id ? data : b)));
|
||||
}, []);
|
||||
|
||||
const deleteBoard = useCallback(async (id: string) => {
|
||||
const { error } = await api.DELETE("/api/boards/{id}", { params: { path: { id } } });
|
||||
if (!error) {
|
||||
setBoards((bs) => bs.filter((b) => b.id !== id));
|
||||
setActiveSlug("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { theme, renderer, accent, wallpaper, customCss, locale } = prefs;
|
||||
|
||||
// t() reads a module-level dict; this effect swaps it when the pref lands.
|
||||
useEffect(() => setLocale(locale), [locale]);
|
||||
|
||||
// Apply theme: <html data-theme> drives all CSS vars. localStorage mirrors
|
||||
// the server setting so index.html can pre-paint without a flash.
|
||||
@@ -80,11 +128,36 @@ export function BoardProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem("dash.theme", theme);
|
||||
}, [theme, loaded]);
|
||||
|
||||
// Appearance overrides: accent is a CSS var the whole theme references,
|
||||
// wallpaper hangs off <body>, customCss lands in a managed <style> tag.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const root = document.documentElement;
|
||||
if (accent) root.style.setProperty("--accent", accent);
|
||||
else root.style.removeProperty("--accent");
|
||||
// Quotes/backslashes stripped so the URL can't break out of url("…").
|
||||
const safeWallpaper = wallpaper.replace(/["\\()]/g, "");
|
||||
document.body.style.backgroundImage = safeWallpaper ? `url("${safeWallpaper}")` : "";
|
||||
document.body.style.backgroundSize = wallpaper ? "cover" : "";
|
||||
document.body.style.backgroundAttachment = wallpaper ? "fixed" : "";
|
||||
let tag = document.getElementById("dash-custom-css");
|
||||
if (customCss) {
|
||||
if (!tag) {
|
||||
tag = document.createElement("style");
|
||||
tag.id = "dash-custom-css";
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
tag.textContent = customCss;
|
||||
} else {
|
||||
tag?.remove();
|
||||
}
|
||||
}, [accent, wallpaper, customCss, loaded]);
|
||||
|
||||
const addSection = useCallback(async (name: string) => {
|
||||
const { data } = await api.POST("/api/sections", { body: { name } });
|
||||
const { data } = await api.POST("/api/sections", { body: { name, boardId } });
|
||||
if (data) setSections((s) => [...s, data]);
|
||||
return data ?? null;
|
||||
}, []);
|
||||
}, [boardId]);
|
||||
|
||||
const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => {
|
||||
setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s)));
|
||||
@@ -195,22 +268,26 @@ export function BoardProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setSetting = useCallback(async (key: keyof Prefs, value: string) => {
|
||||
setPrefs((p) => ({ ...p, [key]: value }));
|
||||
// Prefs uses camelCase; wire keys keep the snake_case convention.
|
||||
const wireKey = key === "customCss" ? "custom_css" : key;
|
||||
const { data, error } = await api.PUT("/api/settings", {
|
||||
body: { [key]: value },
|
||||
body: { [wireKey]: value },
|
||||
});
|
||||
if (error) await refresh();
|
||||
else if (data) setPrefs(parsePrefs(data));
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo<BoardState>(() => ({
|
||||
sections, statuses, theme, renderer, loaded,
|
||||
refresh, addSection, patchSection, deleteSection,
|
||||
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
|
||||
refresh, setActiveSlug, addBoard, renameBoard, deleteBoard,
|
||||
addSection, patchSection, deleteSection,
|
||||
addItem, patchItem, deleteItem, uploadIcon,
|
||||
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
|
||||
setSetting,
|
||||
}), [
|
||||
sections, statuses, theme, renderer, loaded,
|
||||
refresh, addSection, patchSection, deleteSection,
|
||||
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
|
||||
refresh, addBoard, renameBoard, deleteBoard,
|
||||
addSection, patchSection, deleteSection,
|
||||
addItem, patchItem, deleteItem, uploadIcon,
|
||||
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
|
||||
setSetting,
|
||||
|
||||
Reference in new issue
Block a user