Files
geointel/src/client/components/AppearanceSettings.tsx
T
Jens d5ea270329
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
Update GeoIntel project files
2026-07-25 23:43:08 +02:00

812 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}