Initial curated DockDeck source from 26772208f608782e88c736dc70878e7b5abd3916
This commit is contained in:
commit
3c61fba98f
146 files changed
+45229
No files matched your search
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user