Update GeoIntel project files
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-25 23:43:08 +02:00
parent 9db6cca2b1
commit d5ea270329
155 changed files with 37970 additions and 825 deletions
@@ -0,0 +1,811 @@
import {
Accessibility,
AlignJustify,
Check,
CloudSun,
Eye,
GalleryHorizontalEnd,
Gauge,
Grid3X3,
Layers3,
Maximize2,
MonitorCog,
Image as ImageIcon,
RefreshCw,
Server,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type {
Accent,
BackgroundStyle,
ContentWidth,
DashboardPayload,
Density,
Theme,
ViewMode,
WallpaperFit,
} from "../../shared/contracts";
import { api } from "../api";
type Performer = (
action: () => Promise<unknown>,
message: string,
) => Promise<boolean>;
interface AppearanceSettingsProps {
data: DashboardPayload;
perform: Performer;
}
const themes: { id: Theme; label: string; copy: string }[] = [
{
id: "dark",
label: "Donker",
copy: "Diep navy en rustig contrast.",
},
{ id: "light", label: "Licht", copy: "Helder, rustig en verfijnd." },
{ id: "harbor", label: "Haven", copy: "Koel marineblauw voor late avonden." },
];
const accents: { id: Accent; label: string }[] = [
{ id: "coral", label: "Koraal" },
{ id: "ocean", label: "Oceaan" },
{ id: "violet", label: "Violet" },
{ id: "lime", label: "Limoen" },
];
const views: { id: ViewMode; label: string; copy: string }[] = [
{
id: "overview",
label: "Volledig overzicht",
copy: "Alle verzamelingen op één doorlopende pagina.",
},
{
id: "categories",
label: "Categorienavigatie",
copy: "Wissel tussen gerichte verzamelingstabbladen.",
},
];
const widths: {
id: ContentWidth;
label: string;
copy: string;
icon: typeof Maximize2;
}[] = [
{
id: "focused",
label: "Gericht",
copy: "Rustig en gecentreerd tot 1120 px.",
icon: AlignJustify,
},
{
id: "wide",
label: "Breed",
copy: "Evenwichtig voor alledaagse desktops.",
icon: GalleryHorizontalEnd,
},
{
id: "fluid",
label: "Vloeiend",
copy: "Benut ultrabrede schermen volledig.",
icon: Maximize2,
},
];
const densities: {
id: Density;
label: string;
copy: string;
icon: typeof Gauge;
}[] = [
{
id: "compact",
label: "Compact",
copy: "Meer services per rij.",
icon: Grid3X3,
},
{
id: "comfortable",
label: "Comfortabel",
copy: "Een evenwichtige standaard voor dagelijks gebruik.",
icon: Gauge,
},
{
id: "spacious",
label: "Ruim",
copy: "Grotere, rustigere starttegels.",
icon: Layers3,
},
];
const backgrounds: { id: BackgroundStyle; label: string; copy: string }[] = [
{ id: "aurora", label: "Aurora", copy: "Zachte omgevingskleur en diepte." },
{ id: "grid", label: "Blauwdruk", copy: "Een subtiel technisch raster." },
{ id: "minimal", label: "Minimaal", copy: "Zuivere kleur zonder textuur." },
];
export function AppearanceSettings({ data, perform }: AppearanceSettingsProps) {
const preferences = data.preferences;
const wallpaperInput = useRef<HTMLInputElement>(null);
const [wallpaperDraft, setWallpaperDraft] = useState<{
fit: WallpaperFit;
positionX: number;
positionY: number;
zoom: number;
}>(() => ({
fit: preferences.wallpaperFit,
positionX: preferences.wallpaperPositionX,
positionY: preferences.wallpaperPositionY,
zoom: preferences.wallpaperZoom,
}));
useEffect(() => {
setWallpaperDraft({
fit: preferences.wallpaperFit,
positionX: preferences.wallpaperPositionX,
positionY: preferences.wallpaperPositionY,
zoom: preferences.wallpaperZoom,
});
}, [
preferences.wallpaperFit,
preferences.wallpaperPositionX,
preferences.wallpaperPositionY,
preferences.wallpaperZoom,
]);
const wallpaperDraftChanged =
wallpaperDraft.fit !== preferences.wallpaperFit ||
wallpaperDraft.positionX !== preferences.wallpaperPositionX ||
wallpaperDraft.positionY !== preferences.wallpaperPositionY ||
wallpaperDraft.zoom !== preferences.wallpaperZoom;
return (
<>
<header className="settings-heading">
<p className="eyebrow">Instellingen › Weergave</p>
<h2>Thema</h2>
<p>Kies de visuele stijl die het beste bij je workflow past.</p>
</header>
<div className="theme-grid">
{themes.map((theme) => (
<button
key={theme.id}
className={`theme-card ${preferences.theme === theme.id ? "selected" : ""}`}
aria-pressed={preferences.theme === theme.id}
onClick={() =>
perform(
() => api.updatePreferences({ theme: theme.id }),
`Thema ${theme.label} toegepast`,
)
}
>
<span className={`theme-preview ${theme.id}`}>
<i />
<i />
<i />
</span>
<span>
<strong>{theme.label}</strong>
<small>{theme.copy}</small>
</span>
{preferences.theme === theme.id && <Check size={17} />}
</button>
))}
</div>
<div className="canon-preference-columns">
<section>
<h3 className="setting-label">Dashboardmodus</h3>
<div className="view-grid">
{views.map((view) => (
<button
key={view.id}
className={`view-card ${preferences.viewMode === view.id ? "selected" : ""}`}
aria-pressed={preferences.viewMode === view.id}
onClick={() =>
perform(
() => api.updatePreferences({ viewMode: view.id }),
`${view.label} geselecteerd`,
)
}
>
<MonitorCog size={22} />
<span>
<strong>{view.label}</strong>
<small>{view.copy}</small>
</span>
{preferences.viewMode === view.id && <Check size={17} />}
</button>
))}
</div>
</section>
<section>
<h3 className="setting-label">Grid-dichtheid</h3>
<div className="choice-grid density-grid">
{densities.map((density) => {
const Icon = density.icon;
return (
<button
key={density.id}
className={`option-card ${preferences.density === density.id ? "selected" : ""}`}
aria-pressed={preferences.density === density.id}
onClick={() =>
perform(
() => api.updatePreferences({ density: density.id }),
`Tegels ${density.label} geselecteerd`,
)
}
>
<Icon size={20} />
<strong>{density.label}</strong>
<small>{density.copy}</small>
{preferences.density === density.id && <Check size={15} />}
</button>
);
})}
</div>
</section>
</div>
<details className="appearance-advanced">
<summary>
<span>
<strong>Meer personalisatie</strong>
<small>
Accent, canvasbreedte, achtergrond en dashboarddetails
</small>
</span>
</summary>
<div className="appearance-advanced-content">
<h3 className="setting-label">Accentkleur</h3>
<div className="accent-grid">
{accents.map((accent) => (
<button
key={accent.id}
className={`accent-choice ${preferences.accent === accent.id ? "selected" : ""}`}
onClick={() =>
perform(
() => api.updatePreferences({ accent: accent.id }),
`Accent ${accent.label} toegepast`,
)
}
aria-label={`Accent ${accent.label}`}
aria-pressed={preferences.accent === accent.id}
>
<span className={`accent-swatch ${accent.id}`} />
<span>{accent.label}</span>
{preferences.accent === accent.id && <Check size={15} />}
</button>
))}
</div>
<h3 className="setting-label">Ruimtegebruik</h3>
<div className="choice-grid three-up">
{widths.map((width) => {
const Icon = width.icon;
return (
<button
key={width.id}
className={`option-card ${preferences.contentWidth === width.id ? "selected" : ""}`}
aria-pressed={preferences.contentWidth === width.id}
onClick={() =>
perform(
() => api.updatePreferences({ contentWidth: width.id }),
`Canvas ${width.label} geselecteerd`,
)
}
>
<Icon size={20} />
<strong>{width.label}</strong>
<small>{width.copy}</small>
{preferences.contentWidth === width.id && <Check size={15} />}
</button>
);
})}
</div>
<h3 className="setting-label">Achtergrond</h3>
<div className="background-grid">
{backgrounds.map((background) => (
<button
key={background.id}
className={`background-card ${background.id} ${preferences.backgroundStyle === background.id ? "selected" : ""}`}
aria-pressed={preferences.backgroundStyle === background.id}
onClick={() =>
perform(
() =>
api.updatePreferences({ backgroundStyle: background.id }),
`Achtergrond ${background.label} geselecteerd`,
)
}
>
<span />
<strong>{background.label}</strong>
<small>{background.copy}</small>
{preferences.backgroundStyle === background.id && (
<Check size={15} />
)}
</button>
))}
</div>
<section className="wallpaper-settings">
<header className="wallpaper-editor-heading">
<span className="wallpaper-editor-icon">
<ImageIcon size={20} />
</span>
<span>
<strong>Wallpaperstudio</strong>
<small>
Stem de uitsnede af op het zichtbare centrale vlak. De preview
reageert direct; pas toe wanneer het beeld goed staat.
</small>
</span>
<div className="wallpaper-actions">
<input
ref={wallpaperInput}
className="sr-only"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void perform(
() => api.uploadWallpaper(file),
"Wallpaper bijgewerkt",
);
event.target.value = "";
}}
/>
<button
type="button"
className="secondary-button"
onClick={() => wallpaperInput.current?.click()}
>
<Upload size={16} />{" "}
{data.wallpaper ? "Vervangen" : "Uploaden"}
</button>
{data.wallpaper && (
<button
type="button"
className="ghost-danger-button"
onClick={() =>
void perform(
() => api.removeWallpaper(),
"Wallpaper verwijderd",
)
}
>
<Trash2 size={16} /> Verwijderen
</button>
)}
</div>
</header>
<div
className={`wallpaper-editor-preview ${preferences.backgroundStyle} ${data.wallpaper ? "has-wallpaper" : ""}`}
aria-label="Voorbeeld van de dashboardwallpaper"
>
{data.wallpaper ? (
<>
<img
className="wallpaper-preview-image"
src={data.wallpaper.url}
alt="Huidige dashboardwallpaper"
style={{
objectFit: wallpaperDraft.fit,
objectPosition: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
transform: `scale(${wallpaperDraft.zoom / 100})`,
transformOrigin: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
}}
/>
<img
className="wallpaper-preview-ambient"
src={data.wallpaper.url}
alt=""
aria-hidden="true"
style={{
objectPosition: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
}}
/>
</>
) : (
<span className="wallpaper-preview-empty">
<ImageIcon size={30} />
<small>Upload eerst een PNG, JPEG of WebP tot 8 MB.</small>
</span>
)}
<span className="wallpaper-preview-overlay" />
<span className="wallpaper-preview-sidebar" />
<span className="wallpaper-preview-topbar" />
<span className="wallpaper-preview-card one" />
<span className="wallpaper-preview-card two" />
<span className="wallpaper-preview-card three" />
</div>
<div className="wallpaper-editor-controls">
<div className="wallpaper-fit-control">
<span>
<strong>Schaalmethode</strong>
<small>
Passend toont de volledige afbeelding; Vullend benut het
volledige vlak.
</small>
</span>
<div className="segmented-control" aria-label="Schaalmethode">
{(
[
["contain", "Passend"],
["cover", "Vullend"],
] as const
).map(([fit, label]) => (
<button
key={fit}
type="button"
className={wallpaperDraft.fit === fit ? "active" : ""}
aria-pressed={wallpaperDraft.fit === fit}
onClick={() =>
setWallpaperDraft((current) => ({ ...current, fit }))
}
>
{label}
</button>
))}
</div>
</div>
<div className="wallpaper-slider-grid">
<label>
<span>
Horizontale focus{" "}
<strong>{wallpaperDraft.positionX}%</strong>
</span>
<input
type="range"
min="0"
max="100"
value={wallpaperDraft.positionX}
aria-label="Horizontale wallpaperfocus"
onChange={(event) =>
setWallpaperDraft((current) => ({
...current,
positionX: Number(event.target.value),
}))
}
/>
</label>
<label>
<span>
Verticale focus <strong>{wallpaperDraft.positionY}%</strong>
</span>
<input
type="range"
min="0"
max="100"
value={wallpaperDraft.positionY}
aria-label="Verticale wallpaperfocus"
onChange={(event) =>
setWallpaperDraft((current) => ({
...current,
positionY: Number(event.target.value),
}))
}
/>
</label>
<label>
<span>
Zoom <strong>{wallpaperDraft.zoom}%</strong>
</span>
<input
type="range"
min="80"
max="160"
value={wallpaperDraft.zoom}
aria-label="Wallpaperzoom"
onChange={(event) =>
setWallpaperDraft((current) => ({
...current,
zoom: Number(event.target.value),
}))
}
/>
</label>
</div>
<div className="wallpaper-editor-footer">
<small>
De dashboardwallpaper blijft vast in het zichtbare centrale
vlak terwijl de inhoud scrolt.
</small>
<div>
<button
type="button"
className="secondary-button"
onClick={() =>
setWallpaperDraft({
fit: "contain",
positionX: 50,
positionY: 50,
zoom: 100,
})
}
>
Herstellen
</button>
<button
type="button"
className="primary-button"
disabled={!data.wallpaper || !wallpaperDraftChanged}
onClick={() =>
void perform(
() =>
api.updatePreferences({
wallpaperFit: wallpaperDraft.fit,
wallpaperPositionX: wallpaperDraft.positionX,
wallpaperPositionY: wallpaperDraft.positionY,
wallpaperZoom: wallpaperDraft.zoom,
}),
"Wallpaperweergave toegepast",
)
}
>
<Check size={16} /> Toepassen
</button>
</div>
</div>
</div>
</section>
<h3 className="setting-label">Dashboarddetails</h3>
<div className="toggle-list">
<article>
<span className="toggle-icon">
<CloudSun size={18} />
</span>
<span>
<strong>Weer in de topbalk</strong>
<small>
Toon actuele temperatuur en weersituatie voor Mol.
</small>
</span>
<button
className={`switch ${preferences.weatherEnabled ? "on" : ""}`}
role="switch"
aria-label="Weer in de topbalk tonen"
aria-checked={preferences.weatherEnabled}
onClick={() =>
perform(
() =>
api.updatePreferences({
weatherEnabled: !preferences.weatherEnabled,
}),
preferences.weatherEnabled
? "Weer verborgen"
: "Weer getoond",
)
}
>
<span />
</button>
</article>
<article className="weather-location-row">
<span className="toggle-icon">
<CloudSun size={18} />
</span>
<span>
<strong>Weerlocatie</strong>
<small>Naam en coördinaten voor de weerkaart.</small>
</span>
<div className="weather-location-fields">
<input
aria-label="Naam weerlocatie"
defaultValue={preferences.weatherLocation}
onBlur={(event) => {
const value = event.target.value.trim();
if (value && value !== preferences.weatherLocation)
void perform(
() => api.updatePreferences({ weatherLocation: value }),
"Weerlocatie bijgewerkt",
);
}}
/>
<input
aria-label="Breedtegraad"
type="number"
step="0.001"
min="-90"
max="90"
defaultValue={preferences.weatherLatitude}
onBlur={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value))
void perform(
() => api.updatePreferences({ weatherLatitude: value }),
"Breedtegraad bijgewerkt",
);
}}
/>
<input
aria-label="Lengtegraad"
type="number"
step="0.001"
min="-180"
max="180"
defaultValue={preferences.weatherLongitude}
onBlur={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value))
void perform(
() =>
api.updatePreferences({ weatherLongitude: value }),
"Lengtegraad bijgewerkt",
);
}}
/>
</div>
</article>
<article>
<span className="toggle-icon">
<Sparkles size={18} />
</span>
<span>
<strong>Welkomstblok</strong>
<small>
Toon de begroeting, het overzicht en snelle statistieken.
</small>
</span>
<button
className={`switch ${preferences.showHero ? "on" : ""}`}
role="switch"
aria-label="Welkomstblok tonen"
aria-checked={preferences.showHero}
onClick={() =>
perform(
() =>
api.updatePreferences({
showHero: !preferences.showHero,
}),
preferences.showHero
? "Welkomstblok verborgen"
: "Welkomstblok getoond",
)
}
>
<span />
</button>
</article>
<article>
<span className="toggle-icon">
<Eye size={18} />
</span>
<span>
<strong>Servicestatus</strong>
<small>
Toon beschikbaarheidstekst en statusindicatoren op tegels.
</small>
</span>
<button
className={`switch ${preferences.showStatus ? "on" : ""}`}
role="switch"
aria-label="Servicestatus tonen"
aria-checked={preferences.showStatus}
onClick={() =>
perform(
() =>
api.updatePreferences({
showStatus: !preferences.showStatus,
}),
preferences.showStatus
? "Servicestatus verborgen"
: "Servicestatus getoond",
)
}
>
<span />
</button>
</article>
<article>
<span className="toggle-icon">
<Server size={18} />
</span>
<span>
<strong>Unraid-serverpaneel</strong>
<small>
Toon hostcapaciteit, containerstatus en Docker-details.
</small>
</span>
<button
className={`switch ${preferences.showServerWidget ? "on" : ""}`}
role="switch"
aria-label="Unraid-serverpaneel tonen"
aria-checked={preferences.showServerWidget}
onClick={() =>
perform(
() =>
api.updatePreferences({
showServerWidget: !preferences.showServerWidget,
}),
preferences.showServerWidget
? "Unraid-paneel verborgen"
: "Unraid-paneel getoond",
)
}
>
<span />
</button>
</article>
<article>
<span className="toggle-icon">
<Accessibility size={18} />
</span>
<span>
<strong>Verminderde beweging</strong>
<small>
Schakel decoratieve animaties en vloeiende overgangen uit.
</small>
</span>
<button
className={`switch ${preferences.reducedMotion ? "on" : ""}`}
role="switch"
aria-label="Verminderde beweging"
aria-checked={preferences.reducedMotion}
onClick={() =>
perform(
() =>
api.updatePreferences({
reducedMotion: !preferences.reducedMotion,
}),
preferences.reducedMotion
? "Animaties ingeschakeld"
: "Beweging verminderd",
)
}
>
<span />
</button>
</article>
<article>
<span className="toggle-icon">
<RefreshCw size={18} />
</span>
<span>
<strong>Verversingsinterval</strong>
<small>
Hoe vaak DockDeck statussen en actieve panelen opnieuw
ophaalt.
</small>
</span>
<label className="inline-select-control">
<span className="sr-only">Verversingsinterval</span>
<select
name="pollingIntervalMs"
value={preferences.pollingIntervalMs}
aria-label="Verversingsinterval"
onChange={(event) =>
void perform(
() =>
api.updatePreferences({
pollingIntervalMs: Number(event.target.value),
}),
"Verversingsinterval bijgewerkt",
)
}
>
<option value={10000}>10 seconden</option>
<option value={30000}>30 seconden</option>
<option value={60000}>1 minuut</option>
<option value={120000}>2 minuten</option>
<option value={300000}>5 minuten</option>
</select>
</label>
</article>
</div>
</div>
</details>
</>
);
}