Files
DockDeck-Public/src/client/App.tsx
T

239 lines
6.9 KiB
TypeScript

import {
Check,
Grid2X2,
LayoutDashboard,
Settings as SettingsIcon,
} from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { DashboardPayload } from "../shared/contracts";
import { api } from "./api";
import { persistSortOrder, type SortableEntity } from "./persist-sort-order";
import { Dashboard, DashboardSkeleton } from "./components/Dashboard";
import { Settings, type SettingsSection } from "./components/Settings";
import { Brand } from "./components/Brand";
type Page = "dashboard" | "settings";
export function App() {
const [page, setPage] = useState<Page>("dashboard");
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("apps");
const [data, setData] = useState<DashboardPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const toastTimerRef = useRef<number | undefined>(undefined);
const syncInFlightRef = useRef<Promise<void> | null>(null);
const theme = data?.preferences.theme;
const accent = data?.preferences.accent;
const density = data?.preferences.density;
const contentWidth = data?.preferences.contentWidth;
const backgroundStyle = data?.preferences.backgroundStyle;
const reducedMotion = data?.preferences.reducedMotion;
const pollingIntervalMs = data
? Math.min(
data.preferences.pollingIntervalMs,
...data.apps
.filter((app) => app.widgetEnabled)
.map((app) => app.widgetRefreshIntervalMs),
)
: undefined;
const load = useCallback(async () => {
try {
const payload = await api.dashboard();
setData(payload);
setError(null);
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: "DockDeck is niet bereikbaar.",
);
}
}, []);
const sync = useCallback(async () => {
if (syncInFlightRef.current) return syncInFlightRef.current;
const request = (async () => {
setSyncing(true);
try {
await api.sync();
await load();
} catch (syncError) {
setError(
syncError instanceof Error
? syncError.message
: "De actuele status kon niet worden vernieuwd.",
);
} finally {
setSyncing(false);
syncInFlightRef.current = null;
}
})();
syncInFlightRef.current = request;
return request;
}, [load]);
useEffect(() => {
void load().then(sync);
}, [load, sync]);
useLayoutEffect(() => {
if (!theme || !accent || !density || !contentWidth || !backgroundStyle)
return;
const root = document.documentElement;
root.dataset.theme = theme;
root.dataset.accent = accent;
root.dataset.density = density;
root.dataset.contentWidth = contentWidth;
root.dataset.background = backgroundStyle;
root.dataset.reducedMotion = String(Boolean(reducedMotion));
}, [accent, backgroundStyle, contentWidth, density, reducedMotion, theme]);
useEffect(() => {
if (!pollingIntervalMs) return;
const refreshWhenVisible = () => {
if (document.visibilityState === "visible") void sync();
};
const timer = window.setInterval(refreshWhenVisible, pollingIntervalMs);
document.addEventListener("visibilitychange", refreshWhenVisible);
window.addEventListener("online", refreshWhenVisible);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refreshWhenVisible);
window.removeEventListener("online", refreshWhenVisible);
};
}, [pollingIntervalMs, sync]);
const notify = (message: string) => {
window.clearTimeout(toastTimerRef.current);
setToast(message);
toastTimerRef.current = window.setTimeout(() => setToast(null), 2400);
};
const reorderDashboardItems = async (
entity: SortableEntity,
items: readonly { id: string }[],
) => {
try {
await persistSortOrder(entity, items);
await load();
notify(
entity === "app"
? "Appvolgorde bijgewerkt"
: entity === "category"
? "Categorievolgorde bijgewerkt"
: "Favorietenvolgorde bijgewerkt",
);
return true;
} catch (reorderError) {
await load();
notify(
reorderError instanceof Error
? reorderError.message
: "De nieuwe volgorde kon niet worden opgeslagen.",
);
return false;
}
};
useEffect(
() => () => {
window.clearTimeout(toastTimerRef.current);
},
[],
);
if (!data && !error) return <DashboardSkeleton />;
if (!data && error)
return (
<div className="fatal-state">
<Brand />
<p className="eyebrow">VERBINDING VERBROKEN</p>
<h1>DockDeck is even niet bereikbaar.</h1>
<p>{error}</p>
<button className="primary-button" onClick={() => void load()}>
Opnieuw proberen
</button>
</div>
);
if (!data) return null;
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
Naar inhoud
</a>
{page === "dashboard" ? (
<Dashboard
data={data}
syncing={syncing}
onSync={sync}
onReorder={reorderDashboardItems}
onSettings={(section = "apps") => {
setSettingsSection(section);
setPage("settings");
}}
/>
) : (
<Settings
data={data}
initialSection={settingsSection}
onBack={() => setPage("dashboard")}
syncing={syncing}
onSync={sync}
onChanged={load}
notify={notify}
/>
)}
{toast && (
<div className="toast" role="status">
<Check size={16} />
{toast}
</div>
)}
<nav className="mobile-nav" aria-label="Mobiele navigatie">
<button
className={page === "dashboard" ? "active" : ""}
aria-current={page === "dashboard" ? "page" : undefined}
onClick={() => setPage("dashboard")}
>
<LayoutDashboard size={20} />
<span>Dashboard</span>
</button>
<button
className=""
onClick={() => {
setPage("dashboard");
window.setTimeout(
() =>
document
.getElementById("category-navigation")
?.scrollIntoView({ behavior: "smooth" }),
0,
);
}}
>
<Grid2X2 size={20} />
<span>Categorieën</span>
</button>
<button
className={page === "settings" ? "active" : ""}
aria-current={page === "settings" ? "page" : undefined}
onClick={() => setPage("settings")}
>
<SettingsIcon size={20} />
<span>Instellingen</span>
</button>
</nav>
</div>
);
}