239 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|