mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat: phase 2 widgets — registry, fetchers, clock, config dialog
Backend: internal/widget registry (type metadata + Fetcher), Pi-hole v5 / AdGuard / Immich fetchers, /api/widgets/types + /api/widgets/:id/data with 30s TTL cache (failures cached too), kind+config on item create/patch. Config secrets are never logged or echoed in errors. Frontend: WidgetContent host maps config.type to a component, polls /data every 30s for server-backed types; clock renders client-side. Generic WidgetDialog builds its form from the registry's field list. Widget tiles are sortable items in both renderers. 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
6004105038
commit
8afc7df53b
21 files changed
+1072
-40
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Item } from "@/types";
|
||||
import { ClockWidget } from "@/widgets/clock";
|
||||
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||
import { ImmichWidget } from "@/widgets/immich";
|
||||
|
||||
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
||||
// the component that fills it. Adding a widget = one fetcher in Go + one
|
||||
// entry here.
|
||||
|
||||
export type WidgetComponent = (p: { item: Item; data: WidgetData | undefined; error: string | undefined }) => React.ReactNode;
|
||||
|
||||
export interface WidgetData {
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
// Widget payloads are free-form per type; components read fields defensively.
|
||||
function isData(v: unknown): v is WidgetData {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
interface WidgetRegistry {
|
||||
[type: string]: { local?: boolean; view: WidgetComponent };
|
||||
}
|
||||
|
||||
const registry: WidgetRegistry = {
|
||||
clock: { local: true, view: ClockWidget },
|
||||
pihole: { view: PiholeWidget },
|
||||
adguard: { view: AdguardWidget },
|
||||
immich: { view: ImmichWidget },
|
||||
};
|
||||
|
||||
export function cfgStr(item: Item, key: string): string {
|
||||
const cfg = item.config;
|
||||
if (!cfg) return "";
|
||||
const v = cfg[key];
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
export function cfgType(item: Item): string {
|
||||
return cfgStr(item, "type");
|
||||
}
|
||||
|
||||
/** Polls /api/widgets/:id/data every 30s for server-backed widgets. */
|
||||
export function useWidgetData(item: Item): { data: WidgetData | undefined; error: string | undefined } {
|
||||
const type = cfgType(item);
|
||||
const local = registry[type]?.local ?? false;
|
||||
const [state, setState] = useState<{ data: WidgetData | undefined; error: string | undefined }>({
|
||||
data: undefined, error: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !type) return;
|
||||
let dead = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/widgets/${item.id}/data`);
|
||||
const body: unknown = await res.json();
|
||||
if (dead) return;
|
||||
if (!res.ok) {
|
||||
let msg = "fetch failed";
|
||||
if (typeof body === "object" && body !== null && "error" in body) {
|
||||
msg = String(body.error);
|
||||
}
|
||||
setState((s) => ({ data: s.data, error: msg }));
|
||||
} else {
|
||||
let data: WidgetData = {};
|
||||
if (typeof body === "object" && body !== null && "data" in body && isData(body.data)) {
|
||||
data = body.data;
|
||||
}
|
||||
setState({ data, error: undefined });
|
||||
}
|
||||
} catch {
|
||||
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
|
||||
}
|
||||
if (!dead) timer = setTimeout(tick, 30_000);
|
||||
};
|
||||
void tick();
|
||||
return () => {
|
||||
dead = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [item.id, type, local]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** WidgetContent renders inside a renderer's tile chrome. */
|
||||
export function WidgetContent({ item }: { item: Item }) {
|
||||
const { data, error } = useWidgetData(item);
|
||||
const type = cfgType(item);
|
||||
const entry = registry[type];
|
||||
if (!entry) {
|
||||
return <span className="text-[11px] uppercase tracking-[0.1em] text-text-faint">{item.name || "widget"}</span>;
|
||||
}
|
||||
return <>{entry.view({ item, data, error })}</>;
|
||||
}
|
||||
Reference in new issue
Block a user