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,134 @@
|
||||
import { ArrowUpRight, Box, Globe2 } from "lucide-react";
|
||||
import type { DockApp, Favorite } from "../../shared/contracts";
|
||||
import { effectiveAppUrl, favoriteIconUrl } from "../../shared/domain";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
const serviceDescriptions: Array<[RegExp, string]> = [
|
||||
[/plex/i, "Media server & streaming"],
|
||||
[/jellyfin/i, "De vrije software mediaserver"],
|
||||
[/sonarr/i, "PVR voor tv-series"],
|
||||
[/radarr/i, "PVR voor films"],
|
||||
[/home.?assistant|ha-core/i, "Centrale automatisering"],
|
||||
[/adguard|pihole|pi-hole/i, "Netwerkbeveiliging & filter"],
|
||||
[/nextcloud/i, "Bestandshosting"],
|
||||
[/gitea|github|gitlab/i, "Code en samenwerking"],
|
||||
[/immich/i, "Foto's en herinneringen"],
|
||||
[/paperless/i, "Documenten en archief"],
|
||||
];
|
||||
|
||||
function describeService(app: DockApp, categoryName: string) {
|
||||
const haystack = `${app.displayName} ${app.technicalName}`;
|
||||
return (
|
||||
serviceDescriptions.find(([pattern]) => pattern.test(haystack))?.[1] ??
|
||||
`${categoryName} service`
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: DockApp["status"]) {
|
||||
return status === "online"
|
||||
? "Online"
|
||||
: status === "offline"
|
||||
? "Offline"
|
||||
: "Status onbekend";
|
||||
}
|
||||
|
||||
export function AppTile({
|
||||
app,
|
||||
externalAccess,
|
||||
showStatus,
|
||||
index,
|
||||
categoryName,
|
||||
variant = "standard",
|
||||
onLaunch,
|
||||
}: {
|
||||
app: DockApp;
|
||||
externalAccess: boolean;
|
||||
showStatus: boolean;
|
||||
index: number;
|
||||
categoryName: string;
|
||||
variant?: "standard" | "feature";
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}) {
|
||||
const url = effectiveAppUrl(app, externalAccess);
|
||||
const content = (
|
||||
<>
|
||||
<ServiceIcon
|
||||
name={app.displayName}
|
||||
icon={app.icon}
|
||||
hint={`${app.technicalName} ${app.image ?? ""}`}
|
||||
index={index}
|
||||
/>
|
||||
<span className="tile-copy">
|
||||
<strong>{app.displayName}</strong>
|
||||
<small>{describeService(app, categoryName)}</small>
|
||||
</span>
|
||||
{showStatus && (
|
||||
<span className={`tile-status ${app.status}`}>
|
||||
<span /> {statusLabel(app.status)}
|
||||
</span>
|
||||
)}
|
||||
<ArrowUpRight className="tile-arrow" size={17} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
return url ? (
|
||||
<a
|
||||
className={`app-tile ${variant}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="app-tile"
|
||||
onClick={() => onLaunch?.(makeLaunchKey("app", app.id))}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
className={`app-tile ${variant} disabled`}
|
||||
aria-label={`${app.displayName} heeft geen URL`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FavoriteTile({
|
||||
favorite,
|
||||
index,
|
||||
onLaunch,
|
||||
}: {
|
||||
favorite: Favorite;
|
||||
index: number;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className="app-tile"
|
||||
href={favorite.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="favorite-tile"
|
||||
onClick={() => onLaunch?.(makeLaunchKey("favorite", favorite.id))}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={favorite.name}
|
||||
icon={favoriteIconUrl(favorite)}
|
||||
index={index + 2}
|
||||
/>
|
||||
<span className="tile-copy">
|
||||
<strong>{favorite.name}</strong>
|
||||
<small>Favoriet</small>
|
||||
</span>
|
||||
<Globe2 className="status-icon" size={15} aria-hidden="true" />
|
||||
<ArrowUpRight className="tile-arrow" size={17} aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyIcon() {
|
||||
return (
|
||||
<div className="empty-icon" aria-hidden="true">
|
||||
<Box size={28} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
FileUp,
|
||||
Folder,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Category, Favorite } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
import { nextAvailableCategoryIcon } from "../categoryIcons";
|
||||
import {
|
||||
MAX_BOOKMARK_FILE_BYTES,
|
||||
normalizeBookmarkUrl,
|
||||
parseBookmarkHtml,
|
||||
type ParsedBookmark,
|
||||
} from "../bookmark-import";
|
||||
|
||||
type ImportStatus = "ready" | "duplicate" | "error" | "imported";
|
||||
|
||||
interface ImportRow extends ParsedBookmark {
|
||||
status: ImportStatus;
|
||||
statusMessage: string;
|
||||
}
|
||||
|
||||
type Performer = (
|
||||
action: () => Promise<unknown>,
|
||||
message: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
favorites: Favorite[];
|
||||
perform: Performer;
|
||||
}
|
||||
|
||||
function categoryKey(name: string): string {
|
||||
return name.trim().toLocaleLowerCase("nl");
|
||||
}
|
||||
|
||||
function lastSortOrder(items: readonly { sortOrder: number }[]): number {
|
||||
return items.reduce((maximum, item) => Math.max(maximum, item.sortOrder), 0);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "Deze bladwijzer kon niet worden opgeslagen.";
|
||||
}
|
||||
|
||||
export function BookmarkImport({ categories, favorites, perform }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<ImportRow[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const counts = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(total, row) => ({ ...total, [row.status]: total[row.status] + 1 }),
|
||||
{ ready: 0, duplicate: 0, error: 0, imported: 0 },
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
setRows([]);
|
||||
setWarnings([]);
|
||||
setParseError(null);
|
||||
|
||||
if (file.size > MAX_BOOKMARK_FILE_BYTES) {
|
||||
setParseError("Het bladwijzerbestand mag maximaal 2 MB groot zijn.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseBookmarkHtml(await file.text());
|
||||
const knownCategories = new Map(
|
||||
categories.map((category) => [categoryKey(category.name), category]),
|
||||
);
|
||||
const existingByUrl = new Map<string, string>();
|
||||
for (const favorite of favorites) {
|
||||
try {
|
||||
existingByUrl.set(
|
||||
normalizeBookmarkUrl(favorite.url).key,
|
||||
favorite.name,
|
||||
);
|
||||
} catch {
|
||||
// Existing favorites are validated by the API; skip unexpected legacy values.
|
||||
}
|
||||
}
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
const previewRows = parsed.bookmarks.map<ImportRow>((bookmark) => {
|
||||
if (bookmark.error || !bookmark.normalizedUrl || !bookmark.url) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "error",
|
||||
statusMessage: bookmark.error ?? "De bladwijzer is ongeldig.",
|
||||
};
|
||||
}
|
||||
|
||||
const existingName = existingByUrl.get(bookmark.normalizedUrl);
|
||||
if (existingName) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "duplicate",
|
||||
statusMessage: `Deze URL bestaat al als ${existingName}.`,
|
||||
};
|
||||
}
|
||||
if (seenUrls.has(bookmark.normalizedUrl)) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "duplicate",
|
||||
statusMessage: "Deze URL staat al eerder in dit bestand.",
|
||||
};
|
||||
}
|
||||
seenUrls.add(bookmark.normalizedUrl);
|
||||
|
||||
const categoryExists =
|
||||
bookmark.categoryName !== null &&
|
||||
knownCategories.has(categoryKey(bookmark.categoryName));
|
||||
return {
|
||||
...bookmark,
|
||||
status: "ready",
|
||||
statusMessage: bookmark.categoryName
|
||||
? categoryExists
|
||||
? `Map: ${knownCategories.get(categoryKey(bookmark.categoryName))!.name}`
|
||||
: `Nieuwe map: ${bookmark.categoryName}`
|
||||
: "Zonder map",
|
||||
};
|
||||
});
|
||||
|
||||
setRows(previewRows);
|
||||
setWarnings(parsed.warnings);
|
||||
if (previewRows.length === 0) {
|
||||
setParseError(
|
||||
"In dit bestand zijn geen Netscape-bladwijzers gevonden.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setParseError(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
const readyRows = rows.filter((row) => row.status === "ready");
|
||||
if (readyRows.length === 0 || applying) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
await perform(async () => {
|
||||
const categoryIds = new Map<string, string>();
|
||||
const categoryErrors = new Map<string, string>();
|
||||
let favoriteSortOrder = lastSortOrder(favorites);
|
||||
const favoriteSlots = Math.max(0, 10_000 - favoriteSortOrder);
|
||||
const rowsWithOrder = readyRows.slice(0, favoriteSlots);
|
||||
const outcomes = new Map<string, ImportRow>();
|
||||
for (const row of readyRows.slice(favoriteSlots)) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: "Er is geen ruimte meer voor een favorietvolgorde.",
|
||||
});
|
||||
}
|
||||
for (const category of categories) {
|
||||
categoryIds.set(categoryKey(category.name), category.id);
|
||||
}
|
||||
|
||||
const categoryNames = [
|
||||
...new Set(
|
||||
rowsWithOrder
|
||||
.map((row) => row.categoryName)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
];
|
||||
const usedIcons = categories.map((category) => category.icon);
|
||||
let categorySortOrder = lastSortOrder(categories);
|
||||
|
||||
for (const name of categoryNames) {
|
||||
const key = categoryKey(name);
|
||||
if (categoryIds.has(key)) continue;
|
||||
categorySortOrder += 1;
|
||||
if (categorySortOrder > 10_000) {
|
||||
categoryErrors.set(
|
||||
key,
|
||||
"Er is geen ruimte meer voor een nieuwe map.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const icon = nextAvailableCategoryIcon(usedIcons);
|
||||
usedIcons.push(icon);
|
||||
try {
|
||||
const category = await api.createCategory({
|
||||
name,
|
||||
icon,
|
||||
sortOrder: categorySortOrder,
|
||||
});
|
||||
categoryIds.set(key, category.id);
|
||||
} catch (error) {
|
||||
categoryErrors.set(key, errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rowsWithOrder) {
|
||||
const key = row.categoryName ? categoryKey(row.categoryName) : null;
|
||||
if (key && categoryErrors.has(key)) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: `Map niet aangemaakt: ${categoryErrors.get(key)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
favoriteSortOrder += 1;
|
||||
try {
|
||||
await api.createFavorite({
|
||||
name: row.title,
|
||||
url: row.url!,
|
||||
icon: null,
|
||||
categoryId: key ? (categoryIds.get(key) ?? null) : null,
|
||||
sortOrder: favoriteSortOrder,
|
||||
});
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "imported",
|
||||
statusMessage: "Geïmporteerd",
|
||||
});
|
||||
} catch (error) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setRows((current) => current.map((row) => outcomes.get(row.id) ?? row));
|
||||
}, "Bladwijzerimport afgerond");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void readFile(event.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
setFileName(null);
|
||||
setRows([]);
|
||||
setWarnings([]);
|
||||
setParseError(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="settings-heading">
|
||||
<p className="eyebrow">FAVORIETEN EN LINKS</p>
|
||||
<h2>Bladwijzers importeren</h2>
|
||||
<p>
|
||||
Importeer een Netscape Bookmark HTML-export. Mappen worden categorieën
|
||||
en bladwijzers worden favorieten. Bekijk eerst wat wordt toegevoegd.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bookmark-privacy-note">
|
||||
<ShieldCheck size={19} />
|
||||
<span>
|
||||
<strong>Het bestand blijft in deze browser</strong>
|
||||
<small>
|
||||
DockDeck bezoekt, controleert of crawlt de geïmporteerde URL’s niet.
|
||||
Cookies, geschiedenis en wachtwoorden worden niet ingelezen.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className={`bookmark-upload ${dragging ? "is-dragging" : ""}`}
|
||||
aria-label="Bladwijzerbestand uploaden"
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (
|
||||
!event.currentTarget.contains(event.relatedTarget as Node | null)
|
||||
) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="bookmark-file-input"
|
||||
type="file"
|
||||
accept=".html,.htm,text/html,application/xhtml+xml"
|
||||
aria-label="Netscape Bookmark HTML-bestand kiezen"
|
||||
onChange={(event) => {
|
||||
void readFile(event.currentTarget.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="bookmark-upload-icon">
|
||||
<FileUp size={23} />
|
||||
</span>
|
||||
<strong>{fileName ?? "Kies een bookmarkbestand"}</strong>
|
||||
<span>
|
||||
Sleep het Netscape HTML-bestand hierheen of blader naar een bestand
|
||||
</span>
|
||||
<small>HTML · maximaal 2 MB · tot 500 bladwijzers</small>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload size={16} /> Bestand kiezen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{parseError && (
|
||||
<p className="bookmark-import-message error" role="alert">
|
||||
<CircleX size={16} /> {parseError}
|
||||
</p>
|
||||
)}
|
||||
{warnings.map((warning) => (
|
||||
<p
|
||||
className="bookmark-import-message warning"
|
||||
role="status"
|
||||
key={warning}
|
||||
>
|
||||
<AlertCircle size={16} /> {warning}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<section
|
||||
className="bookmark-preview"
|
||||
aria-labelledby="bookmark-preview-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">VOORBEELD</p>
|
||||
<h3 id="bookmark-preview-title">Controleer de import</h3>
|
||||
<p>
|
||||
Bestaande favorieten blijven behouden. Dubbelen en ongeldige
|
||||
items worden overgeslagen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={clearPreview}
|
||||
disabled={applying}
|
||||
>
|
||||
Ander bestand kiezen
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="bookmark-import-summary" aria-label="Importoverzicht">
|
||||
<span>
|
||||
<Check size={15} /> {counts.ready} klaar
|
||||
</span>
|
||||
<span>
|
||||
<AlertCircle size={15} /> {counts.duplicate} dubbelen
|
||||
</span>
|
||||
<span>
|
||||
<CircleX size={15} /> {counts.error} fouten
|
||||
</span>
|
||||
<span>
|
||||
<CircleCheck size={15} /> {counts.imported} toegevoegd
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
className="bookmark-preview-list"
|
||||
aria-label="Bladwijzers in de preview"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<li className={`bookmark-preview-row ${row.status}`} key={row.id}>
|
||||
<span className="bookmark-row-icon" aria-hidden="true">
|
||||
{row.categoryName ? (
|
||||
<Folder size={16} />
|
||||
) : (
|
||||
<BookmarkPlus size={16} />
|
||||
)}
|
||||
</span>
|
||||
<span className="bookmark-row-copy">
|
||||
<strong>{row.title || "Zonder titel"}</strong>
|
||||
{row.status === "error" ? (
|
||||
<small>{row.statusMessage}</small>
|
||||
) : (
|
||||
<>
|
||||
<code>{row.url}</code>
|
||||
<small>{row.statusMessage}</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={`bookmark-row-status ${row.status}`}>
|
||||
{row.status === "ready"
|
||||
? "Klaar"
|
||||
: row.status === "duplicate"
|
||||
? "Dubbel"
|
||||
: row.status === "imported"
|
||||
? "Toegevoegd"
|
||||
: "Fout"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<footer className="bookmark-preview-footer">
|
||||
<span>
|
||||
{counts.ready > 0
|
||||
? `${counts.ready} favorieten worden toegevoegd na bevestiging.`
|
||||
: "Er staan geen nieuwe geldige favorieten klaar."}
|
||||
</span>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => void applyImport()}
|
||||
disabled={counts.ready === 0 || applying}
|
||||
>
|
||||
<Check size={16} />
|
||||
{applying
|
||||
? "Importeren…"
|
||||
: `${counts.ready} favorieten toevoegen`}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface BrandProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function Brand({ compact = false }: BrandProps) {
|
||||
return (
|
||||
<span className={compact ? "brand-lockup compact" : "brand-lockup"}>
|
||||
<img className="dockdeck-mark" src="/dockdeck-mark-v2.svg" alt="" />
|
||||
<span className="brand-product">
|
||||
<strong>DockDeck</strong>
|
||||
{!compact && <small>Jouw digitale thuisbasis</small>}
|
||||
</span>
|
||||
<span
|
||||
className="brand-owner"
|
||||
aria-label="Ontwikkeld door Jens van ITWorx.tech"
|
||||
>
|
||||
<span className="brand-credit">
|
||||
<small>Ontwikkeld door</small>
|
||||
<strong>Jens</strong>
|
||||
</span>
|
||||
<img src="/itworx-wordmark.svg" alt="ITWorx.tech" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { resolveCategoryIcon } from "../categoryIcons";
|
||||
|
||||
interface CategoryIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ name, size = 16 }: CategoryIconProps) {
|
||||
const Icon = resolveCategoryIcon(name);
|
||||
return <Icon size={size} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import {
|
||||
ChevronRight,
|
||||
Code2,
|
||||
PanelsTopLeft,
|
||||
Settings2,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { DashboardPayload } from "../../shared/contracts";
|
||||
import { effectiveAppUrl, sortByPosition } from "../../shared/domain";
|
||||
import { AppTile, EmptyIcon, FavoriteTile } from "./AppTile";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { DashboardHero } from "./DashboardHero";
|
||||
import {
|
||||
DashboardLaunchHistory,
|
||||
type LaunchHistoryItem,
|
||||
} from "./DashboardLaunchHistory";
|
||||
import { ServiceWidgets } from "./ServiceWidgets";
|
||||
import { DashboardTopBar } from "./DashboardTopBar";
|
||||
import { SearchOverlay } from "./SearchOverlay";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { SortableList } from "./SortableList";
|
||||
import { useStoredBoolean } from "../useStoredBoolean";
|
||||
import type { SortableEntity } from "../persist-sort-order";
|
||||
import { reorderWithinSlots } from "../sortable-order";
|
||||
import {
|
||||
makeLaunchKey,
|
||||
selectLaunchHistory,
|
||||
type LaunchKey,
|
||||
} from "../launch-history";
|
||||
import { useLaunchHistory } from "../useLaunchHistory";
|
||||
|
||||
interface Props {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => Promise<void>;
|
||||
onReorder: (
|
||||
entity: SortableEntity,
|
||||
items: readonly { id: string }[],
|
||||
) => Promise<boolean | void>;
|
||||
onSettings: (section?: "general" | "apps" | "favorites") => void;
|
||||
}
|
||||
|
||||
const featureCategory = (name: string) =>
|
||||
/smart|home|domotica|netwerk|network|security|beveilig/i.test(name);
|
||||
|
||||
const compactService = (technicalName: string) =>
|
||||
/homeassistant|authentik|vaultwarden|nginxproxymanager/i.test(
|
||||
technicalName.replace(/[^a-z0-9]/gi, ""),
|
||||
);
|
||||
|
||||
export function Dashboard({
|
||||
data,
|
||||
syncing,
|
||||
onSync,
|
||||
onReorder,
|
||||
onSettings,
|
||||
}: Props) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [leftRailCollapsed, setLeftRailCollapsed] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.leftRailCollapsed",
|
||||
() => false,
|
||||
);
|
||||
const [widgetPanelOpen, setWidgetPanelOpen] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.widgetPanelOpen",
|
||||
() => window.matchMedia("(min-width: 2200px)").matches,
|
||||
);
|
||||
const [personalProjectsOnly, setPersonalProjectsOnly] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.personalProjectsOnly",
|
||||
() => false,
|
||||
);
|
||||
const {
|
||||
entries: launchEntries,
|
||||
record: recordLaunchHistory,
|
||||
clear: clearLaunchHistory,
|
||||
retainAvailable,
|
||||
} = useLaunchHistory();
|
||||
const recordLaunch = useCallback(
|
||||
(key: LaunchKey) => {
|
||||
// Let the anchor's native target=_blank action run before rerendering its
|
||||
// parent. This keeps tracked launches behaving like ordinary links.
|
||||
window.setTimeout(() => recordLaunchHistory(key), 0);
|
||||
},
|
||||
[recordLaunchHistory],
|
||||
);
|
||||
const focusedApps = useMemo(
|
||||
() =>
|
||||
personalProjectsOnly
|
||||
? data.apps.filter((app) => app.personalProject)
|
||||
: data.apps,
|
||||
[data.apps, personalProjectsOnly],
|
||||
);
|
||||
const focusedData = personalProjectsOnly
|
||||
? { ...data, apps: focusedApps }
|
||||
: data;
|
||||
const visibleApps = useMemo(
|
||||
() => sortByPosition(focusedApps.filter((app) => app.visible)),
|
||||
[focusedApps],
|
||||
);
|
||||
const categories = sortByPosition(data.categories);
|
||||
const grouped = categories
|
||||
.map((category) => ({
|
||||
category,
|
||||
apps: visibleApps.filter((app) => app.categoryId === category.id),
|
||||
}))
|
||||
.filter((group) => group.apps.length);
|
||||
const ungroupedApps = visibleApps.filter((app) => !app.categoryId);
|
||||
const allGroups = [
|
||||
...grouped,
|
||||
...(ungroupedApps.length
|
||||
? [
|
||||
{
|
||||
category: {
|
||||
id: "uncategorized",
|
||||
name: "Overig",
|
||||
icon: "Box",
|
||||
sortOrder: 9999,
|
||||
},
|
||||
apps: ungroupedApps,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const shownGroups =
|
||||
activeCategory === "all"
|
||||
? allGroups
|
||||
: allGroups.filter((group) => group.category.id === activeCategory);
|
||||
const degraded = data.integrations.filter(
|
||||
(integration) => integration.state === "degraded",
|
||||
);
|
||||
const onlineApps = visibleApps.filter(
|
||||
(app) => app.status === "online",
|
||||
).length;
|
||||
const newApps = focusedApps.filter((app) => app.isNew).length;
|
||||
const personalProjectCount = data.apps.filter(
|
||||
(app) => app.visible && app.personalProject,
|
||||
).length;
|
||||
const hasVisibleApps = data.apps.some((app) => app.visible);
|
||||
const widgetCount =
|
||||
focusedApps.filter((app) => app.widgetEnabled).length +
|
||||
Number(data.preferences.showServerWidget);
|
||||
const sortedFavorites = sortByPosition(data.favorites);
|
||||
const visibleFavorites =
|
||||
activeCategory === "all"
|
||||
? sortedFavorites
|
||||
: sortedFavorites.filter(
|
||||
(favorite) => favorite.categoryId === activeCategory,
|
||||
);
|
||||
const availableLaunchKeys = useMemo(
|
||||
() =>
|
||||
new Set<string>([
|
||||
...data.apps
|
||||
.filter(
|
||||
(app) =>
|
||||
app.visible &&
|
||||
effectiveAppUrl(app, data.preferences.externalAccess) !== null,
|
||||
)
|
||||
.map((app) => makeLaunchKey("app", app.id)),
|
||||
...data.favorites.map((favorite) =>
|
||||
makeLaunchKey("favorite", favorite.id),
|
||||
),
|
||||
]),
|
||||
[data.apps, data.favorites, data.preferences.externalAccess],
|
||||
);
|
||||
useEffect(() => {
|
||||
retainAvailable(availableLaunchKeys);
|
||||
}, [availableLaunchKeys, retainAvailable]);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
const launchables = new Map<
|
||||
LaunchKey,
|
||||
Omit<LaunchHistoryItem, "count" | "lastOpenedAt">
|
||||
>();
|
||||
for (const app of visibleApps) {
|
||||
const href = effectiveAppUrl(app, data.preferences.externalAccess);
|
||||
if (!href) continue;
|
||||
const key = makeLaunchKey("app", app.id);
|
||||
launchables.set(key, {
|
||||
key,
|
||||
name: app.displayName,
|
||||
href,
|
||||
icon: app.icon,
|
||||
hint: `${app.technicalName} ${app.image ?? ""}`,
|
||||
});
|
||||
}
|
||||
for (const favorite of data.favorites) {
|
||||
const key = makeLaunchKey("favorite", favorite.id);
|
||||
launchables.set(key, {
|
||||
key,
|
||||
name: favorite.name,
|
||||
href: favorite.url,
|
||||
icon: favorite.icon,
|
||||
hint: favorite.url,
|
||||
});
|
||||
}
|
||||
|
||||
const currentKeys = new Set(launchables.keys());
|
||||
const select = (mode: "recent" | "most-used") =>
|
||||
selectLaunchHistory(launchEntries, currentKeys, mode).flatMap((entry) => {
|
||||
const launchable = launchables.get(entry.key);
|
||||
return launchable ? [{ ...entry, ...launchable }] : [];
|
||||
});
|
||||
|
||||
return { recent: select("recent"), mostUsed: select("most-used") };
|
||||
}, [
|
||||
data.favorites,
|
||||
data.preferences.externalAccess,
|
||||
launchEntries,
|
||||
visibleApps,
|
||||
]);
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`dashboard ${data.wallpaper ? "has-wallpaper" : ""}`}
|
||||
id="main-content"
|
||||
style={
|
||||
data.wallpaper
|
||||
? ({
|
||||
"--dashboard-wallpaper-fit": data.preferences.wallpaperFit,
|
||||
"--dashboard-wallpaper-position-x": `${data.preferences.wallpaperPositionX}%`,
|
||||
"--dashboard-wallpaper-position-y": `${data.preferences.wallpaperPositionY}%`,
|
||||
"--dashboard-wallpaper-zoom": String(
|
||||
data.preferences.wallpaperZoom / 100,
|
||||
),
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DashboardTopBar
|
||||
apps={focusedApps}
|
||||
favorites={data.favorites}
|
||||
categories={data.categories}
|
||||
server={data.serverWidget}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
weatherEnabled={data.preferences.weatherEnabled}
|
||||
weatherLocation={data.preferences.weatherLocation}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={() => onSettings("apps")}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
{data.wallpaper && (
|
||||
<div className="dashboard-wallpaper-stage" aria-hidden="true">
|
||||
<img
|
||||
className="dashboard-wallpaper-ambient"
|
||||
src={data.wallpaper.url}
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="dashboard-wallpaper-image"
|
||||
src={data.wallpaper.url}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`dashboard-workspace ${leftRailCollapsed ? "rail-collapsed" : ""}`}
|
||||
>
|
||||
<DashboardSidebar
|
||||
data={focusedData}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={onSettings}
|
||||
collapsed={leftRailCollapsed}
|
||||
onToggleCollapsed={() => setLeftRailCollapsed(!leftRailCollapsed)}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
<div className="dashboard-canvas" id="dashboard-home">
|
||||
<DashboardHero
|
||||
showHero={data.preferences.showHero}
|
||||
newApps={newApps}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={() => onSettings("apps")}
|
||||
/>
|
||||
<DashboardLaunchHistory
|
||||
recent={historyItems.recent}
|
||||
mostUsed={historyItems.mostUsed}
|
||||
hasHistory={launchEntries.length > 0}
|
||||
onLaunch={recordLaunch}
|
||||
onClear={clearLaunchHistory}
|
||||
/>
|
||||
<div className="mobile-dashboard-search">
|
||||
<SearchOverlay
|
||||
apps={focusedApps}
|
||||
favorites={data.favorites}
|
||||
categories={data.categories}
|
||||
server={data.serverWidget}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
enableShortcut={false}
|
||||
ariaLabel="Zoek apps en favorieten mobiel"
|
||||
placeholder="Snel zoeken in services..."
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{degraded.length > 0 && (
|
||||
<div className="degraded-banner" role="status">
|
||||
<span className="status-pulse" />
|
||||
<span>
|
||||
<strong>Beperkte livegegevens.</strong>{" "}
|
||||
{degraded.map((item) => item.label).join(", ")} is niet
|
||||
bereikbaar; je opgeslagen apps blijven bruikbaar.
|
||||
</span>
|
||||
<button onClick={() => void onSync()}>Opnieuw proberen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasVisibleApps && (
|
||||
<div
|
||||
className="category-tabs"
|
||||
id="category-navigation"
|
||||
aria-label="Categorieën"
|
||||
>
|
||||
<button
|
||||
className={personalProjectsOnly ? "active" : ""}
|
||||
aria-pressed={personalProjectsOnly}
|
||||
onClick={() => {
|
||||
setPersonalProjectsOnly(!personalProjectsOnly);
|
||||
setActiveCategory("all");
|
||||
}}
|
||||
>
|
||||
<Code2 size={14} />
|
||||
Mijn projecten
|
||||
<span>{personalProjectCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={activeCategory === "all" ? "active" : ""}
|
||||
onClick={() => setActiveCategory("all")}
|
||||
>
|
||||
<CategoryIcon name="Layers3" size={14} />
|
||||
Alles
|
||||
</button>
|
||||
{allGroups.map((group) => (
|
||||
<button
|
||||
key={group.category.id}
|
||||
className={
|
||||
activeCategory === group.category.id ? "active" : ""
|
||||
}
|
||||
onClick={() => setActiveCategory(group.category.id)}
|
||||
>
|
||||
<CategoryIcon name={group.category.icon} size={14} />
|
||||
{group.category.name}
|
||||
<span>{group.apps.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetCount > 0 && (
|
||||
<details
|
||||
className={`dashboard-insights-panel ${widgetPanelOpen ? "is-expanded" : "is-collapsed"}`}
|
||||
id="dashboard-widgets"
|
||||
open={widgetPanelOpen}
|
||||
onToggle={(event) => setWidgetPanelOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
<span className="insights-summary-icon">
|
||||
<PanelsTopLeft size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Live inzicht</strong>
|
||||
<small>
|
||||
{widgetCount} {widgetCount === 1 ? "paneel" : "panelen"} ·
|
||||
alleen-lezen
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight className="insights-chevron" size={18} />
|
||||
</summary>
|
||||
<div className="dashboard-widgets">
|
||||
<ServiceWidgets
|
||||
data={focusedData}
|
||||
syncing={syncing}
|
||||
onSync={onSync}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{data.favorites.length > 0 && (
|
||||
<section className="favorites-section" id="favorites-section">
|
||||
<header>
|
||||
<h2>
|
||||
<Star size={22} /> Favorieten
|
||||
</h2>
|
||||
</header>
|
||||
{visibleFavorites.length > 0 ? (
|
||||
<SortableList
|
||||
items={visibleFavorites}
|
||||
label="Favorietenvolgorde"
|
||||
className="favorite-grid"
|
||||
orientation="grid"
|
||||
getItemLabel={(favorite) => favorite.name}
|
||||
onReorder={(items) => {
|
||||
const fullOrder = reorderWithinSlots(
|
||||
sortedFavorites,
|
||||
visibleFavorites.map((favorite) => favorite.id),
|
||||
items,
|
||||
);
|
||||
return onReorder("favorite", fullOrder);
|
||||
}}
|
||||
renderItem={(favorite, index, controls) => (
|
||||
<div className="dashboard-sortable-card favorite-sortable-card">
|
||||
<div className="sortable-card-tools dashboard-sort-tools">
|
||||
{controls.dragHandle}
|
||||
{controls.moveUpButton}
|
||||
{controls.moveDownButton}
|
||||
</div>
|
||||
<FavoriteTile
|
||||
favorite={favorite}
|
||||
index={index}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="soft-empty compact-empty">
|
||||
<Star size={20} />
|
||||
<strong>Geen favorieten in deze categorie</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allGroups.length === 0 ? (
|
||||
<section className="empty-state">
|
||||
<EmptyIcon />
|
||||
<p className="eyebrow">
|
||||
{personalProjectsOnly ? "FOCUSFILTER ACTIEF" : "EEN FRIS BEGIN"}
|
||||
</p>
|
||||
<h2>
|
||||
{personalProjectsOnly
|
||||
? "Nog geen eigen projecten gemarkeerd."
|
||||
: "Je DockDeck wacht op zijn eerste app."}
|
||||
</h2>
|
||||
<p>
|
||||
{personalProjectsOnly
|
||||
? "Markeer je zelfgebouwde containers in Instellingen → Apps als eigen project."
|
||||
: `We vonden ${data.apps.length} apps. Kies welke je op je dashboard wilt zien.`}
|
||||
</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => onSettings("apps")}
|
||||
>
|
||||
<Settings2 size={17} />
|
||||
{personalProjectsOnly
|
||||
? "Eigen projecten kiezen"
|
||||
: "Apps kiezen"}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<div className="dashboard-groups">
|
||||
{shownGroups.map((group) => (
|
||||
<section className="app-section" key={group.category.id}>
|
||||
<header>
|
||||
<h2>
|
||||
<CategoryIcon name={group.category.icon} size={22} />
|
||||
{group.category.name}
|
||||
</h2>
|
||||
</header>
|
||||
<SortableList
|
||||
items={group.apps}
|
||||
label={`Apps in ${group.category.name}`}
|
||||
className={`app-grid ${
|
||||
featureCategory(group.category.name) &&
|
||||
group.apps.some(
|
||||
(app) => !compactService(app.technicalName),
|
||||
)
|
||||
? "feature-grid"
|
||||
: ""
|
||||
}`}
|
||||
orientation="grid"
|
||||
getItemLabel={(app) => app.displayName}
|
||||
onReorder={(items) => {
|
||||
const categoryId =
|
||||
group.category.id === "uncategorized"
|
||||
? null
|
||||
: group.category.id;
|
||||
const fullCategoryOrder = sortByPosition(
|
||||
data.apps.filter(
|
||||
(app) => app.categoryId === categoryId,
|
||||
),
|
||||
);
|
||||
const orderedCategoryApps = reorderWithinSlots(
|
||||
fullCategoryOrder,
|
||||
group.apps.map((app) => app.id),
|
||||
items,
|
||||
);
|
||||
return onReorder("app", orderedCategoryApps);
|
||||
}}
|
||||
renderItem={(app, index, controls) => (
|
||||
<div className="dashboard-sortable-card">
|
||||
<div className="sortable-card-tools dashboard-sort-tools">
|
||||
{controls.dragHandle}
|
||||
{controls.moveUpButton}
|
||||
{controls.moveDownButton}
|
||||
</div>
|
||||
<AppTile
|
||||
app={app}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
showStatus={data.preferences.showStatus}
|
||||
index={index}
|
||||
categoryName={group.category.name}
|
||||
onLaunch={recordLaunch}
|
||||
variant={
|
||||
featureCategory(group.category.name) &&
|
||||
!compactService(app.technicalName)
|
||||
? "feature"
|
||||
: "standard"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="dashboard-footer">
|
||||
<span>
|
||||
<span className="online-mark" /> {onlineApps} apps online
|
||||
</span>
|
||||
<button onClick={() => onSettings("apps")}>
|
||||
Dashboard beheren <ChevronRight size={15} />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<main
|
||||
className="dashboard loading-screen"
|
||||
aria-busy="true"
|
||||
aria-label="DockDeck laden"
|
||||
>
|
||||
<div className="skeleton topbar" />
|
||||
<div className="skeleton title" />
|
||||
<div className="skeleton search" />
|
||||
<div className="skeleton section" />
|
||||
<div className="skeleton-grid">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div className="skeleton card" key={item} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChevronRight, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
interface DashboardHeroProps {
|
||||
showHero: boolean;
|
||||
newApps: number;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Goedemorgen";
|
||||
if (hour < 18) return "Goedemiddag";
|
||||
return "Goedenavond";
|
||||
}
|
||||
|
||||
export function DashboardHero({
|
||||
showHero,
|
||||
newApps,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
}: DashboardHeroProps) {
|
||||
if (!showHero)
|
||||
return (
|
||||
<header className="dashboard-toolbar">
|
||||
<div>
|
||||
<p className="eyebrow">JOUW DOCK</p>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button sync-button"
|
||||
onClick={onSync}
|
||||
disabled={syncing}
|
||||
title="Statussen vernieuwen"
|
||||
aria-label="Statussen vernieuwen"
|
||||
>
|
||||
<RefreshCw size={18} className={syncing ? "spin" : ""} />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-copy">
|
||||
<h1>{getGreeting()}</h1>
|
||||
<p className="hero-intro">
|
||||
Welkom terug bij je digitale hoofdkwartier.
|
||||
</p>
|
||||
</div>
|
||||
{newApps > 0 && (
|
||||
<button className="discovery-banner" type="button" onClick={onSettings}>
|
||||
<Sparkles size={21} />
|
||||
<strong>
|
||||
{newApps} {newApps === 1 ? "nieuwe app" : "nieuwe apps"} ontdekt
|
||||
</strong>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Clock3, Eye, EyeOff, History, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { LaunchHistoryEntry, LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
export interface LaunchHistoryItem extends LaunchHistoryEntry {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string | null;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recent: LaunchHistoryItem[];
|
||||
mostUsed: LaunchHistoryItem[];
|
||||
hasHistory: boolean;
|
||||
onLaunch: (key: LaunchKey) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function formatLastOpened(timestamp: number) {
|
||||
const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 1) return "Zojuist geopend";
|
||||
if (minutes < 60) return `${minutes} min geleden`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} uur geleden`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${days === 1 ? "dag" : "dagen"} geleden`;
|
||||
}
|
||||
|
||||
export function DashboardLaunchHistory({
|
||||
recent,
|
||||
mostUsed,
|
||||
hasHistory,
|
||||
onLaunch,
|
||||
onClear,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [mode, setMode] = useState<"recent" | "most-used">("recent");
|
||||
const items = mode === "recent" ? recent : mostUsed;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="dashboard-launch-history"
|
||||
aria-labelledby="launch-history-title"
|
||||
data-testid="launch-history"
|
||||
>
|
||||
<header className="launch-history-header">
|
||||
<div className="launch-history-heading">
|
||||
<h2 id="launch-history-title">
|
||||
<History size={19} aria-hidden="true" /> Snel opnieuw openen
|
||||
</h2>
|
||||
<small>
|
||||
<Clock3 size={13} aria-hidden="true" /> Alleen in deze browser
|
||||
</small>
|
||||
</div>
|
||||
<div className="launch-history-actions">
|
||||
<div
|
||||
className="launch-history-tabs"
|
||||
role="group"
|
||||
aria-label="Snelstartvolgorde"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "recent"}
|
||||
onClick={() => setMode("recent")}
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "most-used"}
|
||||
onClick={() => setMode("most-used")}
|
||||
>
|
||||
Veel gebruikt
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="launch-history-action"
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={!hasHistory}
|
||||
aria-label="Recente geschiedenis wissen"
|
||||
title="Geschiedenis wissen"
|
||||
>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
<span>Wissen</span>
|
||||
</button>
|
||||
<button
|
||||
className="launch-history-action launch-history-toggle"
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<EyeOff size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={15} aria-hidden="true" />
|
||||
)}
|
||||
<span>{expanded ? "Verbergen" : "Tonen"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{expanded && (
|
||||
<div id="launch-history-items">
|
||||
{items.length > 0 ? (
|
||||
<div className="launch-history-grid">
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={item.key}
|
||||
className="launch-history-card"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-launch-key={item.key}
|
||||
onClick={() => onLaunch(item.key)}
|
||||
aria-label={`${item.name}, ${item.count} keer geopend`}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={item.name}
|
||||
icon={item.icon}
|
||||
hint={item.hint}
|
||||
index={index}
|
||||
className="launch-history-icon"
|
||||
/>
|
||||
<span className="launch-history-copy">
|
||||
<strong>{item.name}</strong>
|
||||
<small>
|
||||
{mode === "most-used"
|
||||
? `${item.count} keer geopend`
|
||||
: formatLastOpened(item.lastOpenedAt)}
|
||||
</small>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="launch-history-empty">
|
||||
Je geopende apps en favorieten verschijnen hier.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
PanelsTopLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DashboardPayload } from "../../shared/contracts";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import {
|
||||
favoriteIconUrl,
|
||||
groupFavoritesIntoFolders,
|
||||
} from "../../shared/domain";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: (section: "general" | "favorites") => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
data,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
onLaunch,
|
||||
}: DashboardSidebarProps) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [openFolders, setOpenFolders] = useState<Set<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const favoriteFolders = useMemo(
|
||||
() => groupFavoritesIntoFolders(data.favorites, data.categories),
|
||||
[data.categories, data.favorites],
|
||||
);
|
||||
const visibleApps = data.apps.filter((app) => app.visible);
|
||||
const onlineApps = visibleApps.filter((app) => app.status === "online");
|
||||
const onlinePercent = visibleApps.length
|
||||
? Math.round((onlineApps.length / visibleApps.length) * 100)
|
||||
: 0;
|
||||
const widgetCount =
|
||||
data.apps.filter((app) => app.widgetEnabled).length +
|
||||
Number(data.preferences.showServerWidget);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`dashboard-rail ${collapsed ? "is-collapsed" : ""}`}
|
||||
aria-label="Favorieten en deckstatus"
|
||||
>
|
||||
<section className="rail-intro">
|
||||
<div className="rail-intro-copy">
|
||||
<span>
|
||||
{now.toLocaleDateString("nl-BE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</span>
|
||||
<strong>
|
||||
{now.toLocaleTimeString("nl-BE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="rail-collapse-button"
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={
|
||||
collapsed ? "Linkerzijbalk uitklappen" : "Linkerzijbalk inklappen"
|
||||
}
|
||||
title={
|
||||
collapsed ? "Linkerzijbalk uitklappen" : "Linkerzijbalk inklappen"
|
||||
}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen size={18} />
|
||||
) : (
|
||||
<PanelLeftClose size={18} />
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rail-favorites" id="favorite-sidebar">
|
||||
<header>
|
||||
<span>
|
||||
<Star size={15} /> Favorieten
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettings("favorites")}
|
||||
aria-label="Favorieten en mappen beheren"
|
||||
title="Favorieten en mappen beheren"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</header>
|
||||
{favoriteFolders.length > 0 ? (
|
||||
<nav aria-label="Favorietenmappen">
|
||||
{favoriteFolders.map((folder, folderIndex) => {
|
||||
const isOpen = openFolders.has(folder.id);
|
||||
return (
|
||||
<section className="rail-favorite-folder" key={folder.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="rail-folder-toggle"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`favorite-folder-${folder.id}`}
|
||||
onClick={() =>
|
||||
setOpenFolders((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(folder.id)) next.delete(folder.id);
|
||||
else next.add(folder.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<Folder size={16} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
<small>{folder.favorites.length}</small>
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="rail-folder-links"
|
||||
id={`favorite-folder-${folder.id}`}
|
||||
>
|
||||
{folder.favorites.map((favorite, favoriteIndex) => (
|
||||
<a
|
||||
key={favorite.id}
|
||||
href={favorite.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
onLaunch?.(makeLaunchKey("favorite", favorite.id))
|
||||
}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={favorite.name}
|
||||
icon={favoriteIconUrl(favorite)}
|
||||
hint={favorite.url}
|
||||
index={folderIndex + favoriteIndex}
|
||||
className="rail-app-icon"
|
||||
/>
|
||||
<span>
|
||||
<strong>{favorite.name}</strong>
|
||||
<small>{new URL(favorite.url).hostname}</small>
|
||||
</span>
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : (
|
||||
<button
|
||||
className="rail-empty-action"
|
||||
type="button"
|
||||
onClick={() => onSettings("favorites")}
|
||||
>
|
||||
<Star size={18} />
|
||||
<span>
|
||||
<strong>Maak je eerste favoriet of map</strong>
|
||||
<small>
|
||||
Groepeer je snelle links rechtstreeks in de favorietenbalk
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rail-deck-health" aria-label="Deckstatus">
|
||||
<header>
|
||||
<span>
|
||||
<Sparkles size={14} /> Deckstatus
|
||||
</span>
|
||||
<strong>{onlinePercent}%</strong>
|
||||
</header>
|
||||
<div className="rail-health-bar" aria-hidden="true">
|
||||
<span style={{ width: `${onlinePercent}%` }} />
|
||||
</div>
|
||||
<p>
|
||||
<CheckCircle2 size={13} /> {onlineApps.length} van{" "}
|
||||
{visibleApps.length} zichtbare apps online
|
||||
</p>
|
||||
<button type="button" onClick={onSync} disabled={syncing}>
|
||||
<RefreshCw size={13} className={syncing ? "spin" : ""} />
|
||||
{syncing ? "Status ophalen" : "Nu vernieuwen"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="rail-widget-link"
|
||||
type="button"
|
||||
onClick={() => onSettings("general")}
|
||||
>
|
||||
<span className="rail-widget-icon">
|
||||
<PanelsTopLeft size={19} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Widgets</strong>
|
||||
<small>
|
||||
{widgetCount > 0
|
||||
? `${widgetCount} livepane${widgetCount === 1 ? "el" : "len"}`
|
||||
: "Kies je livepanelen"}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import type {
|
||||
Category,
|
||||
DockApp,
|
||||
Favorite,
|
||||
ServerWidgetMetric,
|
||||
} from "../../shared/contracts";
|
||||
import type { LaunchKey } from "../launch-history";
|
||||
import { Brand } from "./Brand";
|
||||
import { SearchOverlay } from "./SearchOverlay";
|
||||
import { WeatherBadge } from "./WeatherBadge";
|
||||
|
||||
interface DashboardTopBarProps {
|
||||
apps: DockApp[];
|
||||
favorites: Favorite[];
|
||||
categories: Category[];
|
||||
server: ServerWidgetMetric | null;
|
||||
externalAccess: boolean;
|
||||
weatherEnabled: boolean;
|
||||
weatherLocation: string;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: () => void;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function DashboardTopBar({
|
||||
apps,
|
||||
favorites,
|
||||
categories,
|
||||
server,
|
||||
externalAccess,
|
||||
weatherEnabled,
|
||||
weatherLocation,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
onLaunch,
|
||||
}: DashboardTopBarProps) {
|
||||
const scrollTo = (id: string) =>
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||
return (
|
||||
<header className="dashboard-topbar">
|
||||
<div className="dashboard-topbar-inner">
|
||||
<button
|
||||
className="brand-button"
|
||||
type="button"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="Naar boven"
|
||||
>
|
||||
<Brand compact />
|
||||
</button>
|
||||
<div className="topbar-search">
|
||||
<SearchOverlay
|
||||
apps={apps}
|
||||
favorites={favorites}
|
||||
categories={categories}
|
||||
server={server}
|
||||
externalAccess={externalAccess}
|
||||
onLaunch={onLaunch}
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label="Dashboardnavigatie">
|
||||
<button
|
||||
type="button"
|
||||
className="active"
|
||||
onClick={() => scrollTo("dashboard-home")}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button type="button" onClick={onSettings}>
|
||||
Instellingen
|
||||
</button>
|
||||
</nav>
|
||||
<div className="topbar-actions">
|
||||
<WeatherBadge enabled={weatherEnabled} location={weatherLocation} />
|
||||
<button
|
||||
className="icon-button sync-status-button"
|
||||
type="button"
|
||||
onClick={onSync}
|
||||
disabled={syncing}
|
||||
title="Statussen vernieuwen"
|
||||
aria-label="Statussen vernieuwen"
|
||||
>
|
||||
<RefreshCw size={19} className={syncing ? "spin" : ""} />
|
||||
<span className="sync-online-dot" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
title="Instellingen"
|
||||
aria-label="Instellingen openen"
|
||||
>
|
||||
<Settings size={21} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
DashboardPayload,
|
||||
SystemDiagnostics,
|
||||
} from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
interface OperationsSettingsProps {
|
||||
data: DashboardPayload;
|
||||
}
|
||||
|
||||
export function OperationsSettings({ data }: OperationsSettingsProps) {
|
||||
const [diagnostics, setDiagnostics] = useState<SystemDiagnostics | null>(
|
||||
null,
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const run = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
setDiagnostics(await api.diagnostics());
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Diagnostiek mislukt",
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<header className="settings-heading">
|
||||
<p className="eyebrow">STATUS EN TOEGANG</p>
|
||||
<h2>Systeemstatus</h2>
|
||||
<p>
|
||||
Voer een statuscontrole zonder secrets uit en controleer de
|
||||
deploymentgrens voordat DockDeck buiten je vertrouwde LAN bereikbaar
|
||||
wordt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="operations-overview">
|
||||
<article>
|
||||
<Activity size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{data.apps.filter((app) => app.widgetEnabled).length}
|
||||
</strong>
|
||||
<small>actieve app-panelen</small>
|
||||
</span>
|
||||
</article>
|
||||
<article>
|
||||
<CheckCircle2 size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{
|
||||
data.integrations.filter((item) => item.state === "connected")
|
||||
.length
|
||||
}
|
||||
</strong>
|
||||
<small>verbonden integraties</small>
|
||||
</span>
|
||||
</article>
|
||||
<article>
|
||||
<WifiOff size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{
|
||||
data.integrations.filter((item) => item.state === "degraded")
|
||||
.length
|
||||
}
|
||||
</strong>
|
||||
<small>beperkte integraties</small>
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button diagnostics-run"
|
||||
onClick={() => void run()}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw size={15} className={checking ? "spin" : ""} />
|
||||
{checking ? "Controleren" : "Diagnostiek uitvoeren"}
|
||||
</button>
|
||||
{error && <p className="diagnostics-error">{error}</p>}
|
||||
{diagnostics && (
|
||||
<section
|
||||
className={`diagnostics-result ${diagnostics.status}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<header>
|
||||
<ShieldCheck size={20} />
|
||||
<span>
|
||||
<strong>
|
||||
{diagnostics.status === "healthy" ? "gezond" : "beperkt"}
|
||||
</strong>
|
||||
<small>
|
||||
{new Date(diagnostics.generatedAt).toLocaleString()}
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Database</dt>
|
||||
<dd>{diagnostics.database}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unraid-runtime</dt>
|
||||
<dd>{diagnostics.unraidNative}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Verouderde panelen</dt>
|
||||
<dd>{diagnostics.staleWidgets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Onbeschikbare panelen</dt>
|
||||
<dd>{diagnostics.unavailableWidgets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Offline-appshell</dt>
|
||||
<dd>{diagnostics.pwa}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Versie</dt>
|
||||
<dd>{diagnostics.version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
<div className="security-callout operations-security">
|
||||
<ShieldCheck size={20} />
|
||||
<span>
|
||||
<strong>Externe toegang vereist authenticatie</strong>
|
||||
<small>
|
||||
Behoud de standaard lokale binding of plaats DockDeck achter HTTPS
|
||||
en een reverse proxy met authenticatie, zoals Authentik. DockDeck
|
||||
heeft zelf geen accounts en stelt publieke toegang nooit automatisch
|
||||
in.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CornerDownLeft,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
Category,
|
||||
DockApp,
|
||||
Favorite,
|
||||
ServerWidgetMetric,
|
||||
} from "../../shared/contracts";
|
||||
import {
|
||||
effectiveAppUrl,
|
||||
favoriteIconUrl,
|
||||
googleSearchUrl,
|
||||
searchItems,
|
||||
} from "../../shared/domain";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
interface Props {
|
||||
apps: DockApp[];
|
||||
favorites: Favorite[];
|
||||
categories: Category[];
|
||||
server: ServerWidgetMetric | null;
|
||||
externalAccess: boolean;
|
||||
enableShortcut?: boolean;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function SearchOverlay({
|
||||
apps,
|
||||
favorites,
|
||||
categories,
|
||||
server,
|
||||
externalAccess,
|
||||
enableShortcut = true,
|
||||
ariaLabel = "Zoek apps, favorieten of Google",
|
||||
placeholder = "Snel zoeken...",
|
||||
onLaunch,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const results = useMemo(
|
||||
() =>
|
||||
searchItems(query, apps, favorites, externalAccess, categories, server),
|
||||
[query, apps, favorites, externalAccess, categories, server],
|
||||
);
|
||||
const appResults = results.filter((result) => result.kind === "app");
|
||||
const favoriteResults = results.filter(
|
||||
(result) => result.kind === "favorite",
|
||||
);
|
||||
const serverResults = results.filter((result) => result.kind === "server");
|
||||
const orderedResults = [...serverResults, ...appResults, ...favoriteResults];
|
||||
const selectableCount = query.trim() ? orderedResults.length + 1 : 0;
|
||||
const quickLinks = useMemo(
|
||||
() =>
|
||||
[
|
||||
...favorites.slice(0, 4).map((favorite) => ({
|
||||
id: `favorite-${favorite.id}`,
|
||||
name: favorite.name,
|
||||
detail: "Favoriet",
|
||||
url: favorite.url,
|
||||
icon: favoriteIconUrl(favorite),
|
||||
hint: favorite.url,
|
||||
launchKey: makeLaunchKey("favorite", favorite.id),
|
||||
})),
|
||||
...apps
|
||||
.filter((app) => app.visible && effectiveAppUrl(app, externalAccess))
|
||||
.slice(0, 4)
|
||||
.map((app) => ({
|
||||
id: `app-${app.id}`,
|
||||
name: app.displayName,
|
||||
detail: app.status === "online" ? "Online" : "Service",
|
||||
url: effectiveAppUrl(app, externalAccess)!,
|
||||
icon: app.icon,
|
||||
hint: `${app.technicalName} ${app.image ?? ""}`,
|
||||
launchKey: makeLaunchKey("app", app.id),
|
||||
})),
|
||||
].slice(0, 6),
|
||||
[apps, externalAccess, favorites],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (
|
||||
enableShortcut &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === "k"
|
||||
) {
|
||||
event.preventDefault();
|
||||
setActive(true);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [enableShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) inputRef.current?.focus();
|
||||
}, [active]);
|
||||
|
||||
const openUrl = (url: string, launchKey?: LaunchKey) => {
|
||||
if (query.trim()) window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (launchKey) onLaunch?.(launchKey);
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const launchKeyForResult = (result: (typeof orderedResults)[number]) => {
|
||||
if (result.kind === "favorite") {
|
||||
return makeLaunchKey("favorite", result.id);
|
||||
}
|
||||
if (result.kind !== "app" || result.hidden) return undefined;
|
||||
|
||||
const app = apps.find((candidate) => candidate.id === result.id);
|
||||
if (!app?.visible || effectiveAppUrl(app, externalAccess) !== result.url) {
|
||||
return undefined;
|
||||
}
|
||||
return makeLaunchKey("app", app.id);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const result = orderedResults[selectedIndex];
|
||||
if (result) {
|
||||
openUrl(result.url, launchKeyForResult(result));
|
||||
return;
|
||||
}
|
||||
openUrl(googleSearchUrl(query));
|
||||
};
|
||||
|
||||
const openGoogle = () => openUrl(googleSearchUrl(query));
|
||||
|
||||
const searchShell = (
|
||||
<div
|
||||
className={`search-shell ${active ? "expanded" : ""}`}
|
||||
role={active ? "dialog" : undefined}
|
||||
aria-modal={active ? "true" : undefined}
|
||||
aria-label={active ? "Quick Launch" : undefined}
|
||||
>
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-backdrop"
|
||||
aria-label="Quick Launch sluiten"
|
||||
onClick={() => {
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
className="search-bar"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Search size={21} aria-hidden="true" />
|
||||
<input
|
||||
name="quickLaunch"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setActive(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" && selectableCount > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((index) => (index + 1) % selectableCount);
|
||||
}
|
||||
if (event.key === "ArrowUp" && selectableCount > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex(
|
||||
(index) => (index - 1 + selectableCount) % selectableCount,
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-controls={active ? "quick-launch-results" : undefined}
|
||||
aria-activedescendant={
|
||||
active && query.trim() ? `quick-result-${selectedIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
<kbd>{active ? "ESC" : "Ctrl K"}</kbd>
|
||||
</form>
|
||||
{active && (
|
||||
<div
|
||||
className="search-results"
|
||||
id="quick-launch-results"
|
||||
role={query.trim() ? "listbox" : undefined}
|
||||
>
|
||||
{!query.trim() && (
|
||||
<div className="quick-launch-home">
|
||||
<div className="search-hint">
|
||||
<Sparkles size={18} />
|
||||
<span>
|
||||
<strong>Quick Launch</strong>
|
||||
<small>
|
||||
Open een snelkoppeling of typ om alles te doorzoeken.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{quickLinks.length > 0 && (
|
||||
<section
|
||||
className="quick-links-section"
|
||||
aria-labelledby="quick-links-heading"
|
||||
>
|
||||
<h2 id="quick-links-heading">Snelkoppelingen</h2>
|
||||
<div className="quick-links-grid">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
onLaunch?.(link.launchKey);
|
||||
setActive(false);
|
||||
}}
|
||||
>
|
||||
<span className="result-icon">
|
||||
<ServiceIcon
|
||||
name={link.name}
|
||||
icon={link.icon}
|
||||
hint={link.hint}
|
||||
index={index}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{link.name}</strong>
|
||||
<small>{link.detail}</small>
|
||||
</span>
|
||||
<ArrowUpRight size={15} aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
{ label: "Apps", items: [...serverResults, ...appResults] },
|
||||
{ label: "Favorieten", items: favoriteResults },
|
||||
].map((group) =>
|
||||
group.items.length > 0 ? (
|
||||
<section className="result-group" key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
{group.items.map((result) => {
|
||||
const index = orderedResults.indexOf(result);
|
||||
const app =
|
||||
result.kind === "app"
|
||||
? apps.find((candidate) => candidate.id === result.id)
|
||||
: undefined;
|
||||
const favorite =
|
||||
result.kind === "favorite"
|
||||
? favorites.find(
|
||||
(candidate) => candidate.id === result.id,
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<a
|
||||
id={`quick-result-${index}`}
|
||||
key={`${result.kind}-${result.id}`}
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
className={selectedIndex === index ? "selected" : ""}
|
||||
onClick={() => {
|
||||
const launchKey = launchKeyForResult(result);
|
||||
if (launchKey) onLaunch?.(launchKey);
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className="result-icon">
|
||||
{result.kind === "server" ? (
|
||||
<Server size={17} />
|
||||
) : result.kind === "favorite" ? (
|
||||
<ServiceIcon
|
||||
name={result.name}
|
||||
icon={favorite ? favoriteIconUrl(favorite) : null}
|
||||
hint={favorite?.url}
|
||||
index={index}
|
||||
/>
|
||||
) : (
|
||||
<ServiceIcon
|
||||
name={result.name}
|
||||
icon={app?.icon ?? null}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.name}</strong>
|
||||
<small>
|
||||
{result.kind === "app"
|
||||
? `${result.hidden ? "Verborgen app" : app?.status === "online" ? "Online" : app?.status === "offline" ? "Offline" : "Status onbekend"} · ${result.detail}`
|
||||
: result.kind === "server"
|
||||
? `Unraid-server · ${result.detail}`
|
||||
: result.detail}
|
||||
</small>
|
||||
</span>
|
||||
<ArrowUpRight size={16} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : null,
|
||||
)}
|
||||
{query.trim() && (
|
||||
<section className="result-group google-group">
|
||||
<h2>Google</h2>
|
||||
<button
|
||||
id={`quick-result-${orderedResults.length}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === orderedResults.length}
|
||||
className={`google-result ${selectedIndex === orderedResults.length ? "selected" : ""}`}
|
||||
onMouseEnter={() => setSelectedIndex(orderedResults.length)}
|
||||
onClick={openGoogle}
|
||||
>
|
||||
<span className="result-icon google">
|
||||
<ServiceIcon name="Google" icon={null} index={0} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Zoeken op Google naar “{query}”</strong>
|
||||
<small>Opent in een nieuw tabblad</small>
|
||||
</span>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<footer className="search-shortcuts">
|
||||
<span>
|
||||
<kbd>ENTER</kbd> Openen
|
||||
</span>
|
||||
<span>
|
||||
<kbd>ESC</kbd> Sluiten
|
||||
</span>
|
||||
<span>↑ ↓ Navigeren</span>
|
||||
</footer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return active ? createPortal(searchShell, document.body) : searchShell;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BatteryCharging,
|
||||
Boxes,
|
||||
Clock3,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Layers3,
|
||||
MemoryStick,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Thermometer,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ServerWidgetMetric,
|
||||
WidgetChartType,
|
||||
WidgetSize,
|
||||
} from "../../shared/contracts";
|
||||
import { WidgetSparkline } from "./WidgetSparkline";
|
||||
|
||||
function formatMemory(value: number) {
|
||||
return value > 0 ? `${Math.round(value / 1024 ** 3)} GB` : "—";
|
||||
}
|
||||
|
||||
interface ServerWidgetProps {
|
||||
metric: ServerWidgetMetric | null;
|
||||
size: WidgetSize;
|
||||
showStats: boolean;
|
||||
showDetails: boolean;
|
||||
showProgress: boolean;
|
||||
showNative: boolean;
|
||||
showChart: boolean;
|
||||
chartMetric: string;
|
||||
chartType: WidgetChartType;
|
||||
chartSamples: number;
|
||||
showLegend: boolean;
|
||||
hiddenMetrics: string[];
|
||||
metricOrder: string[];
|
||||
statLimit: number;
|
||||
}
|
||||
|
||||
const statLabels = [
|
||||
"CPU cores",
|
||||
"Memory capacity",
|
||||
"Stopped containers",
|
||||
"Array state",
|
||||
"Storage used",
|
||||
"Hottest temperature",
|
||||
"UPS battery",
|
||||
];
|
||||
|
||||
export function ServerWidget({
|
||||
metric,
|
||||
size,
|
||||
showStats,
|
||||
showDetails,
|
||||
showProgress,
|
||||
showNative,
|
||||
showChart,
|
||||
chartMetric,
|
||||
chartType,
|
||||
chartSamples,
|
||||
showLegend,
|
||||
hiddenMetrics,
|
||||
metricOrder,
|
||||
statLimit,
|
||||
}: ServerWidgetProps) {
|
||||
const runningPercent = metric?.containers
|
||||
? Math.round((metric.containersRunning / metric.containers) * 100)
|
||||
: 0;
|
||||
const visibleLabels = statLabels
|
||||
.filter((label) => !hiddenMetrics.includes(label))
|
||||
.sort((left, right) => {
|
||||
const leftIndex = metricOrder.indexOf(left);
|
||||
const rightIndex = metricOrder.indexOf(right);
|
||||
return (
|
||||
(leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
||||
(rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex)
|
||||
);
|
||||
})
|
||||
.slice(0, statLimit);
|
||||
const isShown = (label: string) => visibleLabels.includes(label);
|
||||
const chartHistory = metric?.history?.[chartMetric] ?? [];
|
||||
return (
|
||||
<article
|
||||
className={`service-widget server-widget widget-size-${size}`}
|
||||
data-widget-size={size}
|
||||
>
|
||||
<header>
|
||||
<span className="server-widget-icon">
|
||||
<Server size={22} />
|
||||
</span>
|
||||
<span className="widget-title">
|
||||
<small>UNRAID SERVER</small>
|
||||
{metric ? (
|
||||
<a
|
||||
className="server-title-link"
|
||||
href={metric.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>{metric.hostName}</strong>
|
||||
<ArrowUpRight size={13} />
|
||||
</a>
|
||||
) : (
|
||||
<strong>Wachten op Unraid</strong>
|
||||
)}
|
||||
</span>
|
||||
<span className={`server-health ${metric ? "online" : "unknown"}`}>
|
||||
{metric ? metric.freshness : "Offline"}
|
||||
</span>
|
||||
</header>
|
||||
{showDetails && (
|
||||
<div className="server-summary">
|
||||
<strong>{metric?.containersRunning ?? 0}</strong>
|
||||
<span>
|
||||
containers actief
|
||||
<small>
|
||||
{metric?.operatingSystem ??
|
||||
"Docker-hostinformatie niet beschikbaar"}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showStats && (
|
||||
<div className="provider-stats server-stats">
|
||||
{isShown("CPU cores") && (
|
||||
<span>
|
||||
<Cpu size={14} />
|
||||
<small>CPU-kernen</small>
|
||||
<strong>{metric?.cpuCount ?? "—"}</strong>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Memory capacity") && (
|
||||
<span>
|
||||
<MemoryStick size={14} />
|
||||
<small>Geheugencapaciteit</small>
|
||||
<strong>{formatMemory(metric?.totalMemoryBytes ?? 0)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Stopped containers") && (
|
||||
<span>
|
||||
<Boxes size={14} />
|
||||
<small>Gestopte containers</small>
|
||||
<strong>{metric?.containersStopped ?? "—"}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showNative && metric?.native && (
|
||||
<div className="unraid-native-grid">
|
||||
{isShown("Array state") && (
|
||||
<span>
|
||||
<ShieldCheck size={14} />
|
||||
<small>Arraystatus</small>
|
||||
<strong>{metric.native.arrayState}</strong>
|
||||
<em>
|
||||
{metric.native.parityErrors
|
||||
? `${metric.native.parityErrors} pariteitsfouten`
|
||||
: metric.native.parityStatus}
|
||||
</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Storage used") && (
|
||||
<span>
|
||||
<HardDrive size={14} />
|
||||
<small>Opslag gebruikt</small>
|
||||
<strong>
|
||||
{metric.native.storage.length
|
||||
? formatMemory(
|
||||
metric.native.storage.reduce(
|
||||
(total, group) => total + group.usedBytes,
|
||||
0,
|
||||
),
|
||||
)
|
||||
: "—"}
|
||||
</strong>
|
||||
<em>{metric.native.storage.length} array-/poolgroepen</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Hottest temperature") && (
|
||||
<span>
|
||||
<Thermometer size={14} />
|
||||
<small>Hoogste temperatuur</small>
|
||||
<strong>
|
||||
{metric.native.hottestTemperatureC === null
|
||||
? "—"
|
||||
: `${Math.round(metric.native.hottestTemperatureC)}°C`}
|
||||
</strong>
|
||||
<em>Schijf- en hostsensoren</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("UPS battery") && (
|
||||
<span>
|
||||
<BatteryCharging size={14} />
|
||||
<small>UPS-batterij</small>
|
||||
<strong>{metric.native.upsStatus ?? "Niet beschikbaar"}</strong>
|
||||
<em>
|
||||
{metric.native.upsBatteryPercent === null
|
||||
? "Optionele statusbron"
|
||||
: `${Math.round(metric.native.upsBatteryPercent)}% batterij`}
|
||||
</em>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showChart && chartHistory.length > 1 && (
|
||||
<WidgetSparkline
|
||||
history={chartHistory}
|
||||
chartType={chartType}
|
||||
sampleLimit={chartSamples}
|
||||
label={chartMetric}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
{showDetails && (
|
||||
<div className="server-footer">
|
||||
<span>
|
||||
<Layers3 size={12} /> {metric?.images ?? 0} images
|
||||
</span>
|
||||
<span>
|
||||
<HardDrive size={12} /> Docker {metric?.dockerVersion ?? "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 size={12} />
|
||||
{metric
|
||||
? `Bijgewerkt ${new Date(metric.updatedAt).toLocaleTimeString(
|
||||
[],
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}`
|
||||
: "Wachten op vernieuwing"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div
|
||||
className="memory-track"
|
||||
role="meter"
|
||||
aria-label="Actieve containers"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={runningPercent}
|
||||
>
|
||||
<span style={{ width: `${runningPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
ServerCog,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Preferences,
|
||||
PreferencesPatch,
|
||||
WidgetChartType,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface Props {
|
||||
preferences: Preferences;
|
||||
onChange: (patch: PreferencesPatch, message: string) => void;
|
||||
}
|
||||
|
||||
const statMetrics = [
|
||||
{
|
||||
label: "CPU cores",
|
||||
display: "CPU-kernen",
|
||||
description: "Logische CPU-capaciteit die de Docker-host meldt.",
|
||||
},
|
||||
{
|
||||
label: "Memory capacity",
|
||||
display: "Geheugencapaciteit",
|
||||
description: "Totaal geheugen uit de alleen-lezen inventarisservice.",
|
||||
},
|
||||
{
|
||||
label: "Stopped containers",
|
||||
display: "Gestopte containers",
|
||||
description: "Aanwezige containers die momenteel niet actief zijn.",
|
||||
},
|
||||
{
|
||||
label: "Array state",
|
||||
display: "Arraystatus",
|
||||
description: "Overzicht van de Unraid-array- en pariteitsstatus.",
|
||||
},
|
||||
{
|
||||
label: "Storage used",
|
||||
display: "Opslag gebruikt",
|
||||
description: "Gebruikte capaciteit van de gemelde array en pools.",
|
||||
},
|
||||
{
|
||||
label: "Hottest temperature",
|
||||
display: "Hoogste temperatuur",
|
||||
description: "Hoogste beschikbare temperatuur van host- of opslagsensoren.",
|
||||
},
|
||||
{
|
||||
label: "UPS battery",
|
||||
display: "UPS-batterij",
|
||||
description:
|
||||
"UPS-status en resterende batterij indien ingesteld in Unraid.",
|
||||
},
|
||||
];
|
||||
|
||||
const chartMetrics: Array<[string, string]> = [
|
||||
["Running containers", "Actieve containers"],
|
||||
["Stopped containers", "Gestopte containers"],
|
||||
["Images", "Images"],
|
||||
["Memory capacity", "Geheugencapaciteit"],
|
||||
["Storage used", "Opslag gebruikt"],
|
||||
["Hottest temperature", "Hoogste temperatuur"],
|
||||
["Parity errors", "Pariteitsfouten"],
|
||||
["UPS battery", "UPS-batterij"],
|
||||
];
|
||||
|
||||
export function ServerWidgetAdvancedConfigurator({
|
||||
preferences,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const labels = statMetrics.map((metric) => metric.label);
|
||||
const ordered = [
|
||||
...preferences.serverWidgetMetricOrder.filter((label) =>
|
||||
labels.includes(label),
|
||||
),
|
||||
...labels.filter(
|
||||
(label) => !preferences.serverWidgetMetricOrder.includes(label),
|
||||
),
|
||||
].filter((label) => !preferences.serverWidgetHiddenMetrics.includes(label));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!ordered[target]) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange(
|
||||
{ serverWidgetMetricOrder: next },
|
||||
"Volgorde van Unraid-metingen bijgewerkt",
|
||||
);
|
||||
};
|
||||
return (
|
||||
<details className="widget-advanced-config server-advanced-config">
|
||||
<summary>
|
||||
<ServerCog size={14} /> Volledige Unraid-paneelconfiguratie
|
||||
</summary>
|
||||
<section className="widget-config-section" aria-label="Unraid-grafiek">
|
||||
<header>
|
||||
<BarChart3 size={14} />
|
||||
<span>
|
||||
<strong>Servertrendgrafiek</strong>
|
||||
<small>
|
||||
Toon geschiedenis van Docker-inventaris, opslag, temperatuur,
|
||||
pariteit of UPS.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Reeks</span>
|
||||
<select
|
||||
name="serverWidgetChartMetric"
|
||||
value={preferences.serverWidgetChartMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetChartMetric: event.target.value },
|
||||
"Unraid-grafiekreeks bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{chartMetrics.map(([metric, display]) => (
|
||||
<option key={metric} value={metric}>
|
||||
{display}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Grafiekstijl</span>
|
||||
<select
|
||||
name="serverWidgetChartType"
|
||||
value={preferences.serverWidgetChartType}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetChartType: event.target
|
||||
.value as WidgetChartType,
|
||||
},
|
||||
"Unraid-grafiekstijl bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="line">Lijn</option>
|
||||
<option value="area">Vlak</option>
|
||||
<option value="bar">Balken</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Geschiedenisvenster</span>
|
||||
<select
|
||||
name="serverWidgetChartSamples"
|
||||
value={preferences.serverWidgetChartSamples}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetChartSamples: Number(event.target.value) },
|
||||
"Unraid-grafiekvenster bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{[10, 20, 30, 45, 60].map((samples) => (
|
||||
<option key={samples} value={samples}>
|
||||
Laatste {samples} metingen
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Maximum aantal statistieken</span>
|
||||
<select
|
||||
name="serverWidgetStatLimit"
|
||||
value={preferences.serverWidgetStatLimit}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetStatLimit: Number(event.target.value) },
|
||||
"Limiet voor Unraid-statistieken bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{[3, 4, 5, 6, 7, 8].map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} statistieken
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
preferences.serverWidgetShowChart
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={preferences.serverWidgetShowChart}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetShowChart: !preferences.serverWidgetShowChart,
|
||||
},
|
||||
"Zichtbaarheid van Unraid-grafiek bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<BarChart3 size={15} />
|
||||
<span>
|
||||
<strong>Grafiek tonen</strong>
|
||||
<small>Geschiedenis blijft alleen in het geheugen</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
preferences.serverWidgetShowLegend
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={preferences.serverWidgetShowLegend}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetShowLegend: !preferences.serverWidgetShowLegend,
|
||||
},
|
||||
"Legenda van Unraid-grafiek bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<span>
|
||||
<strong>Legenda tonen</strong>
|
||||
<small>Reeks, laatste waarde en metingen</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="metric-visibility-list" aria-label="Unraid-statistieken">
|
||||
<strong>Serverstatistieken</strong>
|
||||
<small>
|
||||
Kies welke eigen Unraid- en Docker-hostsignalen de kaart mag tonen.
|
||||
</small>
|
||||
<div>
|
||||
{statMetrics.map((metric) => {
|
||||
const visible = !preferences.serverWidgetHiddenMetrics.includes(
|
||||
metric.label,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={metric.label}
|
||||
className={visible ? "active" : ""}
|
||||
aria-pressed={visible}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetHiddenMetrics: visible
|
||||
? [
|
||||
...preferences.serverWidgetHiddenMetrics,
|
||||
metric.label,
|
||||
]
|
||||
: preferences.serverWidgetHiddenMetrics.filter(
|
||||
(label) => label !== metric.label,
|
||||
),
|
||||
},
|
||||
`${metric.display} ${visible ? "verborgen" : "ingeschakeld"}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{metric.display}</span>
|
||||
<small>{metric.description}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{ordered.length > 1 && (
|
||||
<div
|
||||
className="metric-order-list"
|
||||
aria-label="Volgorde van Unraid-metingen"
|
||||
>
|
||||
{ordered.map((metric, index) => (
|
||||
<span key={metric}>
|
||||
<strong>
|
||||
{statMetrics.find((item) => item.label === metric)?.display ??
|
||||
metric}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`${metric} eerder plaatsen`}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === ordered.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`${metric} later plaatsen`}
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
SiAdguard,
|
||||
SiAuthentik,
|
||||
SiDeluge,
|
||||
SiGitea,
|
||||
SiGithub,
|
||||
SiGoogle,
|
||||
SiGrafana,
|
||||
SiHomeassistant,
|
||||
SiImmich,
|
||||
SiJellyfin,
|
||||
SiNextcloud,
|
||||
SiNginxproxymanager,
|
||||
SiOllama,
|
||||
SiPaperlessngx,
|
||||
SiPlex,
|
||||
SiPortainer,
|
||||
SiQbittorrent,
|
||||
SiRadarr,
|
||||
SiSonarr,
|
||||
SiVaultwarden,
|
||||
SiYoutube,
|
||||
} from "@icons-pack/react-simple-icons";
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Braces,
|
||||
Cloud,
|
||||
Database,
|
||||
Download,
|
||||
FileStack,
|
||||
Film,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Image,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Network,
|
||||
Radio,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { useState } from "react";
|
||||
import { inferServiceKind } from "../../shared/domain";
|
||||
|
||||
const hues = ["coral", "violet", "cyan", "lime", "amber"] as const;
|
||||
|
||||
interface ServiceIconProps {
|
||||
name: string;
|
||||
icon: string | null;
|
||||
hint?: string | null;
|
||||
index?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<
|
||||
SVGProps<SVGSVGElement> & { color?: string; size?: number | string }
|
||||
>;
|
||||
|
||||
const brandIcons: Array<[RegExp, IconComponent]> = [
|
||||
[/home.?assistant|ha-core/i, SiHomeassistant],
|
||||
[/paperless/i, SiPaperlessngx],
|
||||
[/nginx.?proxy.?manager|npm/i, SiNginxproxymanager],
|
||||
[/q.?bittorrent/i, SiQbittorrent],
|
||||
[/vaultwarden/i, SiVaultwarden],
|
||||
[/adguard/i, SiAdguard],
|
||||
[/authentik/i, SiAuthentik],
|
||||
[/nextcloud/i, SiNextcloud],
|
||||
[/portainer/i, SiPortainer],
|
||||
[/jellyfin/i, SiJellyfin],
|
||||
[/youtube/i, SiYoutube],
|
||||
[/github/i, SiGithub],
|
||||
[/google/i, SiGoogle],
|
||||
[/grafana/i, SiGrafana],
|
||||
[/immich/i, SiImmich],
|
||||
[/deluge/i, SiDeluge],
|
||||
[/sonarr/i, SiSonarr],
|
||||
[/radarr/i, SiRadarr],
|
||||
[/ollama/i, SiOllama],
|
||||
[/gitea/i, SiGitea],
|
||||
[/plex/i, SiPlex],
|
||||
];
|
||||
|
||||
const semanticIcons: Record<
|
||||
ReturnType<typeof inferServiceKind>,
|
||||
IconComponent
|
||||
> = {
|
||||
dns: ShieldCheck,
|
||||
media: Film,
|
||||
photos: Image,
|
||||
automation: Workflow,
|
||||
downloads: Download,
|
||||
monitoring: Gauge,
|
||||
code: Braces,
|
||||
ai: Bot,
|
||||
documents: FileStack,
|
||||
security: LockKeyhole,
|
||||
service: Box,
|
||||
};
|
||||
|
||||
const infrastructureIcons: Array<[RegExp, IconComponent]> = [
|
||||
[/postgres|mariadb|mysql|mongo|redis|database|db\b/i, Database],
|
||||
[/cloud|cloudflare/i, Cloud],
|
||||
[/mail|smtp/i, Mail],
|
||||
[/network|proxy|gateway/i, Network],
|
||||
[/radio|mqtt/i, Radio],
|
||||
[/worker|workflow|queue/i, Workflow],
|
||||
[/storage|disk|nas/i, HardDrive],
|
||||
[/server|service/i, Server],
|
||||
];
|
||||
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
|
||||
|
||||
function resolveIconUrl(icon: string) {
|
||||
try {
|
||||
const url = new URL(icon, window.location.href);
|
||||
if (
|
||||
loopbackHosts.has(url.hostname) &&
|
||||
!loopbackHosts.has(window.location.hostname)
|
||||
) {
|
||||
url.hostname = window.location.hostname;
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
function inferredIcon(name: string, hint: string | null | undefined) {
|
||||
const haystack = `${name} ${hint ?? ""}`;
|
||||
const BrandIcon = brandIcons.find(([pattern]) => pattern.test(haystack))?.[1];
|
||||
if (BrandIcon) return { Icon: BrandIcon, brand: true };
|
||||
const InfrastructureIcon = infrastructureIcons.find(([pattern]) =>
|
||||
pattern.test(haystack),
|
||||
)?.[1];
|
||||
return {
|
||||
Icon:
|
||||
InfrastructureIcon ?? semanticIcons[inferServiceKind(haystack)] ?? Box,
|
||||
brand: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ServiceIcon({
|
||||
name,
|
||||
icon,
|
||||
hint,
|
||||
index = 0,
|
||||
className = "",
|
||||
}: ServiceIconProps) {
|
||||
const [failedIcon, setFailedIcon] = useState<string | null>(null);
|
||||
const resolvedIcon = icon ? resolveIconUrl(icon) : null;
|
||||
if (resolvedIcon && failedIcon !== resolvedIcon)
|
||||
return (
|
||||
<img
|
||||
className={`app-icon-image ${className}`}
|
||||
src={resolvedIcon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailedIcon(resolvedIcon)}
|
||||
/>
|
||||
);
|
||||
const { Icon, brand } = inferredIcon(name, hint);
|
||||
return (
|
||||
<span
|
||||
className={`app-brand-icon ${brand ? "actual-brand" : "semantic"} ${hues[index % hues.length]} ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon color={brand ? "default" : "currentColor"} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
Code2,
|
||||
Clock3,
|
||||
Cpu,
|
||||
Download,
|
||||
Film,
|
||||
Files,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Image,
|
||||
LockKeyhole,
|
||||
MemoryStick,
|
||||
Network,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
DockApp,
|
||||
IntegrationState,
|
||||
WidgetCapability,
|
||||
WidgetMetric,
|
||||
WidgetMetricDefinition,
|
||||
WidgetValueHistoryPoint,
|
||||
} from "../../shared/contracts";
|
||||
import {
|
||||
effectiveAppUrl,
|
||||
inferServiceKind,
|
||||
type ServiceKind,
|
||||
} from "../../shared/domain";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
import { WidgetSparkline } from "./WidgetSparkline";
|
||||
|
||||
const kindDetails: Record<
|
||||
ServiceKind,
|
||||
{ label: string; icon: typeof Activity }
|
||||
> = {
|
||||
dns: { label: "DNS-bescherming", icon: ShieldCheck },
|
||||
media: { label: "Streamingactiviteit", icon: Film },
|
||||
photos: { label: "Bibliotheekverwerking", icon: Image },
|
||||
automation: { label: "Automatiseringsengine", icon: Bot },
|
||||
downloads: { label: "Downloadengine", icon: Download },
|
||||
monitoring: { label: "Live telemetrie", icon: Gauge },
|
||||
code: { label: "Repositoryservice", icon: Code2 },
|
||||
ai: { label: "Lokale intelligentie", icon: BrainCircuit },
|
||||
documents: { label: "Documentworkflow", icon: Files },
|
||||
security: { label: "Beveiligde service", icon: LockKeyhole },
|
||||
service: { label: "Servicestatus", icon: HardDrive },
|
||||
};
|
||||
|
||||
function formatBytes(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
if (value < 1024 ** 2) return `${Math.round(value / 1024)} KB`;
|
||||
if (value < 1024 ** 3) return `${Math.round(value / 1024 ** 2)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
if (value < 1024) return `${Math.round(value)} B/s`;
|
||||
if (value < 1024 ** 2) return `${Math.round(value / 1024)} KB/s`;
|
||||
return `${(value / 1024 ** 2).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string | null | undefined): string {
|
||||
if (!value) return "Wachten op vernieuwing";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "Vernieuwingstijd onbekend";
|
||||
return `Bijgewerkt ${parsed.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
}
|
||||
|
||||
interface TailoredStat {
|
||||
label: string;
|
||||
value: string;
|
||||
numericValue?: number;
|
||||
icon?: typeof Activity;
|
||||
tone?: "default" | "positive" | "warning";
|
||||
}
|
||||
|
||||
function numericDisplayValue(value: string): number | null {
|
||||
const match = value
|
||||
.trim()
|
||||
.replace(/,/g, "")
|
||||
.match(/^(-?\d+(?:\.\d+)?)\s*([kmgt])?/i);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
const suffix = match[2]?.toLowerCase();
|
||||
return (
|
||||
parsed *
|
||||
(suffix === "t"
|
||||
? 1e12
|
||||
: suffix === "g"
|
||||
? 1e9
|
||||
: suffix === "m"
|
||||
? 1e6
|
||||
: suffix === "k"
|
||||
? 1e3
|
||||
: 1)
|
||||
);
|
||||
}
|
||||
|
||||
function capabilityFallback(
|
||||
capability: WidgetCapability,
|
||||
app: DockApp,
|
||||
metric: WidgetMetric | undefined,
|
||||
) {
|
||||
const stats: TailoredStat[] = [
|
||||
{
|
||||
label: "Status",
|
||||
value: app.status,
|
||||
tone: app.status === "online" ? "positive" : "warning",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: "CPU",
|
||||
value:
|
||||
metric?.cpuPercent == null ? "—" : `${metric.cpuPercent.toFixed(1)}%`,
|
||||
numericValue: metric?.cpuPercent ?? undefined,
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
label: "Geheugen",
|
||||
value: formatBytes(metric?.memoryUsageBytes ?? null),
|
||||
numericValue: metric?.memoryUsageBytes ?? undefined,
|
||||
icon: MemoryStick,
|
||||
},
|
||||
{
|
||||
label: "Inkomend",
|
||||
value: formatRate(metric?.networkRxBytesPerSecond),
|
||||
numericValue: metric?.networkRxBytesPerSecond ?? undefined,
|
||||
icon: ArrowDown,
|
||||
},
|
||||
{
|
||||
label: "Uitgaand",
|
||||
value: formatRate(metric?.networkTxBytesPerSecond),
|
||||
numericValue: metric?.networkTxBytesPerSecond ?? undefined,
|
||||
icon: ArrowUp,
|
||||
},
|
||||
{
|
||||
label: "Processen",
|
||||
value: metric?.pids == null ? "—" : String(metric.pids),
|
||||
numericValue: metric?.pids ?? undefined,
|
||||
icon: Activity,
|
||||
},
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? `${capability.label} is beschikbaar`
|
||||
: `${capability.label} vraagt aandacht`,
|
||||
detail:
|
||||
app.status === "online"
|
||||
? "Dockertelemetrie actief · alleen-lezen"
|
||||
: (metric?.statusText ?? "Container is offline"),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
function infrastructureHistory(
|
||||
metric: WidgetMetric | undefined,
|
||||
key: "cpu" | "memory" | "network",
|
||||
): WidgetValueHistoryPoint[] {
|
||||
if (!metric) return [];
|
||||
return metric.history.flatMap((point) => {
|
||||
const value =
|
||||
key === "memory"
|
||||
? point.memoryPercent
|
||||
: key === "network"
|
||||
? point.networkRxBytesPerSecond
|
||||
: point.cpuPercent;
|
||||
return value === null ? [] : [{ sampledAt: point.sampledAt, value }];
|
||||
});
|
||||
}
|
||||
|
||||
function tailoredFallback(
|
||||
kind: ServiceKind,
|
||||
app: DockApp,
|
||||
metric: WidgetMetric | undefined,
|
||||
providerState: IntegrationState | undefined,
|
||||
): { summary: string; detail: string; stats: TailoredStat[] } {
|
||||
const incoming = metric?.networkRxBytesPerSecond;
|
||||
const outgoing = metric?.networkTxBytesPerSecond;
|
||||
const memory = formatBytes(metric?.memoryUsageBytes ?? null);
|
||||
const cpu =
|
||||
metric?.cpuPercent == null ? "—" : `${metric.cpuPercent.toFixed(1)}%`;
|
||||
if (kind === "downloads") {
|
||||
return {
|
||||
summary:
|
||||
incoming != null && incoming > 64 * 1024
|
||||
? `Downloaden met ${formatRate(incoming)}`
|
||||
: "Downloadengine is inactief",
|
||||
detail: metric?.statusText ?? "Wachten op overdrachtstelemetrie",
|
||||
stats: [
|
||||
{
|
||||
label: "Downloadsnelheid",
|
||||
value: formatRate(incoming),
|
||||
icon: ArrowDown,
|
||||
tone: incoming ? "positive" : "default",
|
||||
},
|
||||
{ label: "Uploadsnelheid", value: formatRate(outgoing), icon: ArrowUp },
|
||||
{ label: "Werkgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === "dns") {
|
||||
return {
|
||||
summary: "DNS-container is gekoppeld",
|
||||
detail:
|
||||
providerState?.state === "degraded"
|
||||
? "Dockertelemetrie actief · optionele DNS-details niet bereikbaar"
|
||||
: (metric?.statusText ?? "Resolververkeer wordt gemonitord"),
|
||||
stats: [
|
||||
{
|
||||
label: "DNS-inkomend",
|
||||
value: formatRate(incoming),
|
||||
icon: ArrowDown,
|
||||
tone: "positive",
|
||||
},
|
||||
{ label: "DNS-uitgaand", value: formatRate(outgoing), icon: ArrowUp },
|
||||
{ label: "Resolvergeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === "media")
|
||||
return {
|
||||
summary: `Belasting media-engine ${cpu}`,
|
||||
detail: metric?.statusText ?? "Wachten op streamingtelemetrie",
|
||||
stats: [
|
||||
{ label: "Transcodebelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Streamverkeer", value: formatRate(outgoing), icon: Film },
|
||||
{ label: "Mediageheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "photos")
|
||||
return {
|
||||
summary: `Belasting bibliotheekworker ${cpu}`,
|
||||
detail: metric?.statusText ?? "Wachten op bibliotheektelemetrie",
|
||||
stats: [
|
||||
{ label: "Indexbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Uploads", value: formatRate(incoming), icon: Image },
|
||||
{ label: "Bibliotheekgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "automation")
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? "Automatiseringsengine reageert"
|
||||
: "Automatiseringen niet beschikbaar",
|
||||
detail: metric?.statusText ?? "Wachten op automatiseringstelemetrie",
|
||||
stats: [
|
||||
{ label: "Eventbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Eventverkeer", value: formatRate(incoming), icon: Network },
|
||||
{ label: "Statusgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "ai")
|
||||
return {
|
||||
summary:
|
||||
metric?.cpuPercent != null && metric.cpuPercent > 5
|
||||
? `Inferentiewerklast actief · ${cpu} CPU`
|
||||
: "AI-runtime is gereed",
|
||||
detail: metric?.statusText ?? "Wachten op modeltelemetrie",
|
||||
stats: [
|
||||
{ label: "Inferentiebelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Modelverkeer", value: formatRate(outgoing), icon: Network },
|
||||
{ label: "Modelgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "documents")
|
||||
return {
|
||||
summary:
|
||||
incoming != null && incoming > 32 * 1024
|
||||
? "Documentinvoer is actief"
|
||||
: "Documentworkflow staat stand-by",
|
||||
detail: metric?.statusText ?? "Wachten op archieftelemetrie",
|
||||
stats: [
|
||||
{ label: "Workerbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Invoer", value: formatRate(incoming), icon: ArrowDown },
|
||||
{ label: "Archiefgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "security")
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? "Beveiligde service is beschikbaar"
|
||||
: "Beveiligde service vraagt aandacht",
|
||||
detail:
|
||||
metric?.statusText ??
|
||||
"Wachten op telemetrie van de beveiligingsservice",
|
||||
stats: [
|
||||
{ label: "Verzoekbelasting", value: cpu, icon: Cpu },
|
||||
{
|
||||
label: "Beveiligd verkeer",
|
||||
value: formatRate(incoming),
|
||||
icon: Network,
|
||||
},
|
||||
{ label: "Servicegeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
return {
|
||||
summary:
|
||||
app.status === "online" ? "Service reageert" : "Service vraagt aandacht",
|
||||
detail: metric?.statusText ?? "Wachten op live telemetrie",
|
||||
stats: [
|
||||
{ label: "Procesbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Live inkomend", value: formatRate(incoming), icon: ArrowDown },
|
||||
{ label: "Werkgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface ServiceWidgetProps {
|
||||
app: DockApp;
|
||||
metric?: WidgetMetric;
|
||||
capability?: WidgetCapability;
|
||||
providerState?: IntegrationState;
|
||||
externalAccess: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function ServiceWidget({
|
||||
app,
|
||||
metric,
|
||||
capability,
|
||||
providerState,
|
||||
externalAccess,
|
||||
index,
|
||||
}: ServiceWidgetProps) {
|
||||
const kind = inferServiceKind(`${app.technicalName} ${app.displayName}`);
|
||||
const details = kindDetails[kind];
|
||||
const KindIcon = details.icon;
|
||||
const url = effectiveAppUrl(app, externalAccess);
|
||||
const fallback = capability
|
||||
? capabilityFallback(capability, app, metric)
|
||||
: tailoredFallback(kind, app, metric, providerState);
|
||||
const memoryPercent =
|
||||
metric?.memoryUsageBytes != null &&
|
||||
metric.memoryLimitBytes != null &&
|
||||
metric.memoryLimitBytes > 0
|
||||
? Math.min(100, (metric.memoryUsageBytes / metric.memoryLimitBytes) * 100)
|
||||
: 0;
|
||||
const freshness =
|
||||
metric?.provider?.freshness ?? metric?.freshness ?? "unavailable";
|
||||
const providerThresholdStat = metric?.provider?.stats.find(
|
||||
(stat) => stat.label === app.widgetWarningMetric,
|
||||
);
|
||||
const thresholdValue =
|
||||
app.widgetWarningMetric === "memory"
|
||||
? memoryPercent
|
||||
: app.widgetWarningMetric === "network"
|
||||
? metric?.networkRxBytesPerSecond
|
||||
: app.widgetWarningMetric === "cpu"
|
||||
? metric?.cpuPercent
|
||||
: (providerThresholdStat?.numericValue ??
|
||||
(providerThresholdStat
|
||||
? numericDisplayValue(providerThresholdStat.value)
|
||||
: null));
|
||||
const warning =
|
||||
app.widgetWarningThreshold !== null &&
|
||||
thresholdValue != null &&
|
||||
(app.widgetWarningDirection === "below"
|
||||
? thresholdValue <= app.widgetWarningThreshold
|
||||
: thresholdValue >= app.widgetWarningThreshold);
|
||||
const displayStats: TailoredStat[] = metric?.provider
|
||||
? metric.provider.stats.map((stat) => ({ ...stat }))
|
||||
: fallback.stats;
|
||||
const orderedStats = displayStats
|
||||
.filter((stat) => !app.widgetHiddenMetrics.includes(stat.label))
|
||||
.sort((left, right) => {
|
||||
if (app.widgetPrimaryMetric) {
|
||||
if (left.label === app.widgetPrimaryMetric) return -1;
|
||||
if (right.label === app.widgetPrimaryMetric) return 1;
|
||||
}
|
||||
const leftIndex = app.widgetMetricOrder.indexOf(left.label);
|
||||
const rightIndex = app.widgetMetricOrder.indexOf(right.label);
|
||||
return (
|
||||
(leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
||||
(rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex)
|
||||
);
|
||||
})
|
||||
.slice(0, app.widgetStatLimit);
|
||||
const definitions = new Map(
|
||||
capability?.metrics.map((definition) => [definition.label, definition]) ??
|
||||
[],
|
||||
);
|
||||
const requestedChart = app.widgetChartMetric;
|
||||
const providerHistory = metric?.provider?.history ?? {};
|
||||
const automaticProviderSeries = Object.entries(providerHistory).find(
|
||||
([, history]) => history.length > 1,
|
||||
);
|
||||
const chartMetric =
|
||||
requestedChart === "auto"
|
||||
? (automaticProviderSeries?.[0] ?? "cpu")
|
||||
: requestedChart;
|
||||
const chartHistory =
|
||||
chartMetric === "cpu" ||
|
||||
chartMetric === "memory" ||
|
||||
chartMetric === "network"
|
||||
? infrastructureHistory(metric, chartMetric)
|
||||
: (providerHistory[chartMetric] ?? []);
|
||||
const body = (
|
||||
<>
|
||||
<header>
|
||||
<ServiceIcon
|
||||
name={app.displayName}
|
||||
icon={app.icon}
|
||||
index={index}
|
||||
className="widget-service-icon"
|
||||
/>
|
||||
<span className="widget-title">
|
||||
<small>
|
||||
{app.widgetCustomLabel || metric?.provider?.label || details.label}
|
||||
</small>
|
||||
<strong>{app.displayName}</strong>
|
||||
</span>
|
||||
<span className={`widget-freshness ${freshness}`}>{freshness}</span>
|
||||
<span className={`widget-kind ${kind}`} aria-hidden="true">
|
||||
<KindIcon size={17} />
|
||||
</span>
|
||||
</header>
|
||||
{app.widgetShowSummary && (
|
||||
<div className="widget-state">
|
||||
<span className={`status-dot ${app.status}`} />
|
||||
<strong>{metric?.provider?.summary ?? fallback.summary}</strong>
|
||||
<small>
|
||||
{metric?.provider
|
||||
? "Provider-API · alleen-lezen live data"
|
||||
: fallback.detail}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowMetrics && (
|
||||
<div className="provider-stats tailored-stats">
|
||||
{orderedStats.map((stat) => {
|
||||
const StatIcon = stat.icon;
|
||||
const definition = definitions.get(stat.label) as
|
||||
WidgetMetricDefinition | undefined;
|
||||
const presentation =
|
||||
app.widgetMetricStyles[stat.label] ??
|
||||
definition?.defaultStyle ??
|
||||
"value";
|
||||
const numericValue =
|
||||
stat.numericValue ?? numericDisplayValue(stat.value);
|
||||
const presentationValue =
|
||||
numericValue === null
|
||||
? null
|
||||
: Math.max(0, Math.min(100, numericValue));
|
||||
return (
|
||||
<span
|
||||
className={`${stat.tone ?? "default"} metric-style-${presentation}`}
|
||||
key={stat.label}
|
||||
>
|
||||
{StatIcon && <StatIcon size={13} />}
|
||||
<small>{stat.label}</small>
|
||||
<strong>{stat.value}</strong>
|
||||
{presentation !== "value" && presentationValue !== null && (
|
||||
<span
|
||||
className="stat-visual"
|
||||
role="meter"
|
||||
aria-label={`${stat.label} ${presentation}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(presentationValue)}
|
||||
>
|
||||
<i style={{ width: `${presentationValue}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowSparkline && chartHistory.length > 1 && (
|
||||
<WidgetSparkline
|
||||
history={chartHistory}
|
||||
chartType={app.widgetChartType}
|
||||
sampleLimit={app.widgetChartSamples}
|
||||
label={
|
||||
chartMetric === "cpu"
|
||||
? "Container-CPU"
|
||||
: chartMetric === "memory"
|
||||
? "Containergeheugen"
|
||||
: chartMetric === "network"
|
||||
? "Inkomend netwerkverkeer"
|
||||
: chartMetric
|
||||
}
|
||||
showLegend={app.widgetShowLegend}
|
||||
/>
|
||||
)}
|
||||
{app.widgetShowInfrastructure && (
|
||||
<div className="widget-infra-line">
|
||||
<span>
|
||||
<Cpu size={12} />{" "}
|
||||
{metric?.cpuPercent == null
|
||||
? "—"
|
||||
: `${metric.cpuPercent.toFixed(1)}%`}
|
||||
</span>
|
||||
<span>
|
||||
<MemoryStick size={12} />{" "}
|
||||
{formatBytes(metric?.memoryUsageBytes ?? null)}
|
||||
</span>
|
||||
<span>
|
||||
<Network size={12} /> {formatRate(metric?.networkRxBytesPerSecond)}
|
||||
</span>
|
||||
<span>
|
||||
<Activity size={12} /> {metric?.pids ?? "—"} PIDs
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 size={12} />
|
||||
{formatUpdatedAt(metric?.provider?.updatedAt ?? metric?.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowProgress && (
|
||||
<div
|
||||
className="memory-track"
|
||||
role="meter"
|
||||
aria-label="Geheugengebruik"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(memoryPercent)}
|
||||
>
|
||||
<span style={{ width: `${memoryPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{url && <ArrowUpRight className="widget-arrow" size={17} />}
|
||||
</>
|
||||
);
|
||||
return url ? (
|
||||
<a
|
||||
className={`service-widget ${app.status} service-widget-${kind} widget-size-${app.widgetSize}${warning ? " widget-warning" : ""}`}
|
||||
data-widget-size={app.widgetSize}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<article
|
||||
className={`service-widget ${app.status} service-widget-${kind} widget-size-${app.widgetSize}${warning ? " widget-warning" : ""}`}
|
||||
data-widget-size={app.widgetSize}
|
||||
>
|
||||
{body}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Activity, Check, RefreshCw, Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { DashboardPayload, WidgetSize } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
import { ServiceWidget } from "./ServiceWidget";
|
||||
import { ServerWidget } from "./ServerWidget";
|
||||
import { WidgetEditToolbar } from "./WidgetEditToolbar";
|
||||
|
||||
interface ServiceWidgetsProps {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ServiceWidgets({ data, syncing, onSync }: ServiceWidgetsProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const apps = data.apps
|
||||
.filter((app) => app.widgetEnabled)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.widgetOrder - right.widgetOrder ||
|
||||
left.displayName.localeCompare(right.displayName),
|
||||
);
|
||||
const showServer = data.preferences.showServerWidget;
|
||||
if (apps.length === 0 && !showServer) return null;
|
||||
const items: Array<
|
||||
| {
|
||||
kind: "server";
|
||||
id: "server";
|
||||
order: number;
|
||||
size: WidgetSize;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
kind: "app";
|
||||
id: string;
|
||||
order: number;
|
||||
size: WidgetSize;
|
||||
label: string;
|
||||
}
|
||||
> = [
|
||||
...(showServer
|
||||
? [
|
||||
{
|
||||
kind: "server" as const,
|
||||
id: "server" as const,
|
||||
order: data.preferences.serverWidgetOrder,
|
||||
size: data.preferences.serverWidgetSize,
|
||||
label: "Unraid server",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...apps.map((app) => ({
|
||||
kind: "app" as const,
|
||||
id: app.id,
|
||||
order: app.widgetOrder,
|
||||
size: app.widgetSize,
|
||||
label: app.displayName,
|
||||
})),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
left.order - right.order || left.label.localeCompare(right.label),
|
||||
);
|
||||
|
||||
const perform = async (action: () => Promise<unknown>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await action();
|
||||
await onSync();
|
||||
setEditError(null);
|
||||
} catch (error) {
|
||||
setEditError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "De indelingswijziging kon niet worden opgeslagen",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!items[target]) return;
|
||||
const reordered = [...items];
|
||||
[reordered[index], reordered[target]] = [
|
||||
reordered[target]!,
|
||||
reordered[index]!,
|
||||
];
|
||||
void perform(() =>
|
||||
api.updateWidgetLayout({
|
||||
items: reordered.map((item, position) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
order: position * 10,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="widgets-section" aria-labelledby="widgets-heading">
|
||||
<header>
|
||||
<div>
|
||||
<p className="section-kicker">LIVE UIT JE DOCKER-OMGEVING</p>
|
||||
<h2 id="widgets-heading">
|
||||
<Activity size={18} /> Servicepanelen
|
||||
</h2>
|
||||
</div>
|
||||
<div className="widgets-actions">
|
||||
<button
|
||||
className={editing ? "widgets-edit active" : "widgets-edit"}
|
||||
onClick={() => setEditing((value) => !value)}
|
||||
aria-pressed={editing}
|
||||
>
|
||||
{editing ? <Check size={13} /> : <Settings2 size={13} />}
|
||||
{editing ? "Klaar" : "Indeling bewerken"}
|
||||
</button>
|
||||
<button
|
||||
className="widgets-refresh"
|
||||
onClick={() => void onSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw size={13} className={syncing ? "spin" : ""} />
|
||||
{syncing
|
||||
? "Vernieuwen"
|
||||
: items.length === 1
|
||||
? "1 livepaneel"
|
||||
: `${items.length} livepanelen`}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{editError && (
|
||||
<p className="widget-edit-error" role="alert">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className={editing ? "widgets-grid editing" : "widgets-grid"}>
|
||||
{items.map((item, index) => {
|
||||
const app =
|
||||
item.kind === "app"
|
||||
? apps.find((candidate) => candidate.id === item.id)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
className={`widget-shell widget-size-${item.size}`}
|
||||
data-widget-size={item.size}
|
||||
key={`${item.kind}-${item.id}`}
|
||||
>
|
||||
{editing && (
|
||||
<WidgetEditToolbar
|
||||
label={item.label}
|
||||
size={item.size}
|
||||
first={index === 0}
|
||||
last={index === items.length - 1}
|
||||
disabled={saving || syncing}
|
||||
onMove={(direction) => move(index, direction)}
|
||||
onSizeChange={(size) =>
|
||||
void perform(() =>
|
||||
item.kind === "server"
|
||||
? api.updatePreferences({ serverWidgetSize: size })
|
||||
: api.updateApp(item.id, { widgetSize: size }),
|
||||
)
|
||||
}
|
||||
onHide={() =>
|
||||
void perform(() =>
|
||||
item.kind === "server"
|
||||
? api.updatePreferences({ showServerWidget: false })
|
||||
: api.updateApp(item.id, { widgetEnabled: false }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.kind === "server" ? (
|
||||
<ServerWidget
|
||||
metric={data.serverWidget}
|
||||
size={data.preferences.serverWidgetSize}
|
||||
showStats={data.preferences.serverWidgetShowStats}
|
||||
showDetails={data.preferences.serverWidgetShowDetails}
|
||||
showProgress={data.preferences.serverWidgetShowProgress}
|
||||
showNative={data.preferences.serverWidgetShowNative}
|
||||
showChart={data.preferences.serverWidgetShowChart}
|
||||
chartMetric={data.preferences.serverWidgetChartMetric}
|
||||
chartType={data.preferences.serverWidgetChartType}
|
||||
chartSamples={data.preferences.serverWidgetChartSamples}
|
||||
showLegend={data.preferences.serverWidgetShowLegend}
|
||||
hiddenMetrics={data.preferences.serverWidgetHiddenMetrics}
|
||||
metricOrder={data.preferences.serverWidgetMetricOrder}
|
||||
statLimit={data.preferences.serverWidgetStatLimit}
|
||||
/>
|
||||
) : app ? (
|
||||
<ServiceWidget
|
||||
app={app}
|
||||
metric={data.widgetMetrics[app.technicalName]}
|
||||
capability={data.widgetCapabilities.find(
|
||||
(capability) => capability.appId === app.id,
|
||||
)}
|
||||
providerState={data.integrations.find(
|
||||
(integration) =>
|
||||
integration.id === `widget-${app.technicalName}`,
|
||||
)}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
index={index}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,296 @@
|
||||
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
|
||||
import { isSortableDropTarget } from "../sortable-order";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface SortableControls {
|
||||
dragHandle: ReactNode;
|
||||
moveDownButton: ReactNode;
|
||||
moveUpButton: ReactNode;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
interface SortableListProps<T extends { id: string }> {
|
||||
items: readonly T[];
|
||||
label: string;
|
||||
className: string;
|
||||
emptyMessage?: string;
|
||||
orientation?: "vertical" | "horizontal" | "grid";
|
||||
getItemLabel: (item: T) => string;
|
||||
onReorder: (items: T[]) => void | boolean | Promise<void | boolean>;
|
||||
renderItem: (item: T, index: number, controls: SortableControls) => ReactNode;
|
||||
}
|
||||
|
||||
interface DropTarget {
|
||||
id: string;
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
interface PointerDrag {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const moveItem = <T extends { id: string }>(
|
||||
items: readonly T[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
after: boolean,
|
||||
) => {
|
||||
const sourceIndex = items.findIndex((item) => item.id === sourceId);
|
||||
const targetIndex = items.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex)
|
||||
return [...items];
|
||||
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(sourceIndex, 1);
|
||||
if (!moved) return [...items];
|
||||
const adjustedTarget = targetIndex - Number(sourceIndex < targetIndex);
|
||||
next.splice(adjustedTarget + Number(after), 0, moved);
|
||||
return next;
|
||||
};
|
||||
|
||||
export function SortableList<T extends { id: string }>({
|
||||
items,
|
||||
label,
|
||||
className,
|
||||
emptyMessage,
|
||||
orientation = "vertical",
|
||||
getItemLabel,
|
||||
onReorder,
|
||||
renderItem,
|
||||
}: SortableListProps<T>) {
|
||||
const [optimisticItems, setOptimisticItems] = useState<T[] | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const pointerDrag = useRef<PointerDrag | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
const orderedItems = optimisticItems ?? [...items];
|
||||
|
||||
const commitOrder = async (nextItems: T[], movedId: string) => {
|
||||
if (savingRef.current) return;
|
||||
const nextPosition = nextItems.findIndex((item) => item.id === movedId) + 1;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setOptimisticItems(nextItems);
|
||||
setAnnouncement(
|
||||
`${getItemLabel(nextItems[nextPosition - 1]!)} verplaatst naar positie ${nextPosition} van ${nextItems.length}.`,
|
||||
);
|
||||
try {
|
||||
const saved = await onReorder(nextItems);
|
||||
if (saved === false) {
|
||||
setOptimisticItems(null);
|
||||
setAnnouncement("De nieuwe volgorde kon niet worden opgeslagen.");
|
||||
}
|
||||
} catch {
|
||||
setOptimisticItems(null);
|
||||
setAnnouncement("De nieuwe volgorde kon niet worden opgeslagen.");
|
||||
} finally {
|
||||
setOptimisticItems(null);
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveBy = (itemId: string, offset: -1 | 1) => {
|
||||
const sourceIndex = orderedItems.findIndex((item) => item.id === itemId);
|
||||
const targetIndex = sourceIndex + offset;
|
||||
if (
|
||||
sourceIndex < 0 ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= orderedItems.length
|
||||
)
|
||||
return;
|
||||
const next = [...orderedItems];
|
||||
[next[sourceIndex], next[targetIndex]] = [
|
||||
next[targetIndex]!,
|
||||
next[sourceIndex]!,
|
||||
];
|
||||
void commitOrder(next, itemId);
|
||||
};
|
||||
|
||||
const resetPointerDrag = () => {
|
||||
pointerDrag.current = null;
|
||||
setDraggingId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
const handlePointerDown = (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
id: string,
|
||||
) => {
|
||||
if (savingRef.current || event.button !== 0 || event.isPrimary === false)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pointerDrag.current = {
|
||||
id,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = pointerDrag.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
|
||||
if (
|
||||
!drag.active &&
|
||||
Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 6
|
||||
)
|
||||
return;
|
||||
|
||||
if (!drag.active) {
|
||||
drag.active = true;
|
||||
setDraggingId(drag.id);
|
||||
}
|
||||
|
||||
const elementAtPointer = document
|
||||
.elementFromPoint?.(event.clientX, event.clientY)
|
||||
?.closest<HTMLElement>("[data-sortable-id]");
|
||||
const eventTarget = (event.target as HTMLElement | null)?.closest?.(
|
||||
"[data-sortable-id]",
|
||||
);
|
||||
const target = elementAtPointer ?? eventTarget;
|
||||
const targetId = (target as HTMLElement | null)?.dataset.sortableId;
|
||||
if (
|
||||
!target ||
|
||||
!targetId ||
|
||||
!isSortableDropTarget(target, event.currentTarget) ||
|
||||
targetId === drag.id
|
||||
) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = target.getBoundingClientRect();
|
||||
const isVertical =
|
||||
orientation === "vertical" ||
|
||||
(orientation === "grid" &&
|
||||
Math.abs(event.clientY - (bounds.top + bounds.height / 2)) >
|
||||
Math.abs(event.clientX - (bounds.left + bounds.width / 2)));
|
||||
const after = isVertical
|
||||
? event.clientY > bounds.top + bounds.height / 2
|
||||
: event.clientX > bounds.left + bounds.width / 2;
|
||||
setDropTarget({ id: targetId, after });
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = pointerDrag.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const destination = dropTarget;
|
||||
resetPointerDrag();
|
||||
if (!drag.active || !destination) return;
|
||||
const next = moveItem(
|
||||
orderedItems,
|
||||
drag.id,
|
||||
destination.id,
|
||||
destination.after,
|
||||
);
|
||||
if (next.some((item, index) => item.id !== orderedItems[index]?.id))
|
||||
void commitOrder(next, drag.id);
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return emptyMessage ? (
|
||||
<div className={`${className} sortable-list-empty`} role="status">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`sortable-list ${className}`}
|
||||
role="list"
|
||||
aria-label={label}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={resetPointerDrag}
|
||||
>
|
||||
{orderedItems.map((item, index) => {
|
||||
const itemLabel = getItemLabel(item);
|
||||
const isDragging = draggingId === item.id;
|
||||
const isDropTarget = dropTarget?.id === item.id;
|
||||
const handle = (
|
||||
<button
|
||||
className="sortable-handle"
|
||||
type="button"
|
||||
aria-label={`Sleep ${itemLabel}; gebruik de knoppen omhoog en omlaag voor toetsenbordbediening`}
|
||||
title="Sleep om te verplaatsen"
|
||||
disabled={saving}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => handlePointerDown(event, item.id)}
|
||||
>
|
||||
<GripVertical size={15} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const moveUpButton = (
|
||||
<button
|
||||
className="sortable-move-button"
|
||||
type="button"
|
||||
aria-label={`${itemLabel} omhoog verplaatsen`}
|
||||
title="Omhoog verplaatsen"
|
||||
disabled={saving || index === 0}
|
||||
onClick={() => moveBy(item.id, -1)}
|
||||
>
|
||||
<ArrowUp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const moveDownButton = (
|
||||
<button
|
||||
className="sortable-move-button"
|
||||
type="button"
|
||||
aria-label={`${itemLabel} omlaag verplaatsen`}
|
||||
title="Omlaag verplaatsen"
|
||||
disabled={saving || index === orderedItems.length - 1}
|
||||
onClick={() => moveBy(item.id, 1)}
|
||||
>
|
||||
<ArrowDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const targetPosition =
|
||||
isDropTarget && dropTarget
|
||||
? dropTarget.after
|
||||
? "after"
|
||||
: "before"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sortable-list-item ${isDragging ? "is-dragging" : ""} ${targetPosition ? `drop-${targetPosition}` : ""}`}
|
||||
data-sortable-id={item.id}
|
||||
data-sortable-index={index}
|
||||
aria-busy={saving || undefined}
|
||||
role="listitem"
|
||||
key={item.id}
|
||||
>
|
||||
{renderItem(item, index, {
|
||||
dragHandle: handle,
|
||||
moveDownButton,
|
||||
moveUpButton,
|
||||
isDragging,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Moon,
|
||||
Snowflake,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WeatherMetric } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
const iconFor = (weather: WeatherMetric | null) => {
|
||||
if (!weather) return CloudSun;
|
||||
const code = weather.weatherCode;
|
||||
if (code === 0) return weather.isDay ? Sun : Moon;
|
||||
if (code <= 3) return weather.isDay ? CloudSun : CloudMoon;
|
||||
if (code === 45 || code === 48) return CloudFog;
|
||||
if (code <= 67 || (code >= 80 && code <= 82)) return CloudRain;
|
||||
if (code <= 77 || (code >= 85 && code <= 86)) return Snowflake;
|
||||
if (code >= 95) return CloudLightning;
|
||||
return Cloud;
|
||||
};
|
||||
|
||||
export function WeatherBadge({
|
||||
enabled,
|
||||
location,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
location: string;
|
||||
}) {
|
||||
const [weather, setWeather] = useState<WeatherMetric | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void api
|
||||
.weather()
|
||||
.then((value) => active && setWeather(value))
|
||||
.catch(() => active && setWeather(null));
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 10 * 60_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [enabled, location]);
|
||||
|
||||
if (!enabled) return null;
|
||||
const Icon = iconFor(weather);
|
||||
return (
|
||||
<span
|
||||
className={`weather-badge ${weather ? "ready" : "loading"}`}
|
||||
title={
|
||||
weather ? `${weather.summary} in ${weather.location}` : "Weer laden"
|
||||
}
|
||||
aria-label={
|
||||
weather
|
||||
? `${Math.round(weather.temperatureC)} graden, ${weather.summary} in ${weather.location}`
|
||||
: "Weer laden"
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<strong>
|
||||
{weather ? `${Math.round(weather.temperatureC)}°` : "--°"}
|
||||
</strong>
|
||||
<span>{weather?.location ?? location}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Tag,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AppPatch,
|
||||
DockApp,
|
||||
WidgetMetricDefinition,
|
||||
WidgetMetricStyle,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface WidgetAdvancedConfiguratorProps {
|
||||
app: DockApp;
|
||||
availableMetrics: WidgetMetricDefinition[];
|
||||
onChange: (patch: AppPatch, message: string) => void;
|
||||
}
|
||||
|
||||
const refreshOptions = [
|
||||
{ value: 15_000, label: "15 seconden" },
|
||||
{ value: 30_000, label: "30 seconden" },
|
||||
{ value: 60_000, label: "1 minuut" },
|
||||
{ value: 120_000, label: "2 minuten" },
|
||||
{ value: 300_000, label: "5 minuten" },
|
||||
];
|
||||
|
||||
const chartSamples = [10, 20, 30, 45, 60];
|
||||
const infrastructureMetrics = [
|
||||
{ label: "cpu", name: "Container-CPU" },
|
||||
{ label: "memory", name: "Containergeheugen" },
|
||||
{ label: "network", name: "Inkomend netwerkverkeer" },
|
||||
];
|
||||
|
||||
export function WidgetAdvancedConfigurator({
|
||||
app,
|
||||
availableMetrics,
|
||||
onChange,
|
||||
}: WidgetAdvancedConfiguratorProps) {
|
||||
const metricLabels = availableMetrics.map((metric) => metric.label);
|
||||
const orderedMetrics = [
|
||||
...app.widgetMetricOrder.filter((label) => metricLabels.includes(label)),
|
||||
...metricLabels.filter((label) => !app.widgetMetricOrder.includes(label)),
|
||||
].filter((label) => !app.widgetHiddenMetrics.includes(label));
|
||||
const chartableMetrics = availableMetrics.filter(
|
||||
(metric) => metric.chartable,
|
||||
);
|
||||
const warningMetrics = [
|
||||
...infrastructureMetrics,
|
||||
...availableMetrics
|
||||
.filter((metric) => metric.unit !== "text")
|
||||
.map((metric) => ({ label: metric.label, name: metric.label })),
|
||||
];
|
||||
const moveMetric = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!orderedMetrics[target]) return;
|
||||
const next = [...orderedMetrics];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange({ widgetMetricOrder: next }, "Volgorde van metingen bijgewerkt");
|
||||
};
|
||||
const setMetricStyle = (label: string, style: WidgetMetricStyle) =>
|
||||
onChange(
|
||||
{
|
||||
widgetMetricStyles: { ...app.widgetMetricStyles, [label]: style },
|
||||
},
|
||||
`Weergave van ${label} bijgewerkt`,
|
||||
);
|
||||
|
||||
return (
|
||||
<details className="widget-advanced-config">
|
||||
<summary>
|
||||
<Gauge size={14} /> Volledige paneelconfiguratie
|
||||
</summary>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>
|
||||
<Tag size={13} /> Eigen paneellabel
|
||||
</span>
|
||||
<input
|
||||
name={`widgetLabel-${app.id}`}
|
||||
defaultValue={app.widgetCustomLabel ?? ""}
|
||||
placeholder="Gebruik een appspecifiek label"
|
||||
maxLength={64}
|
||||
onBlur={(event) => {
|
||||
const value = event.target.value.trim() || null;
|
||||
if (value !== app.widgetCustomLabel)
|
||||
onChange(
|
||||
{ widgetCustomLabel: value },
|
||||
"Paneellabel bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primaire statistiek</span>
|
||||
<select
|
||||
name={`widgetPrimaryMetric-${app.id}`}
|
||||
value={app.widgetPrimaryMetric ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetPrimaryMetric: event.target.value || null },
|
||||
"Primaire meting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Automatisch</option>
|
||||
{orderedMetrics.map((metric) => (
|
||||
<option value={metric} key={metric}>
|
||||
{metric}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Maximum aantal statistieken</span>
|
||||
<select
|
||||
name={`widgetStatLimit-${app.id}`}
|
||||
value={app.widgetStatLimit}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetStatLimit: Number(event.target.value) },
|
||||
"Limiet voor statistieken bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, index) => index + 1).map(
|
||||
(limit) => (
|
||||
<option value={limit} key={limit}>
|
||||
{limit} statistieken
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Paneelvolgorde</span>
|
||||
<input
|
||||
name={`widgetOrder-${app.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10_000}
|
||||
defaultValue={app.widgetOrder}
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (value !== app.widgetOrder)
|
||||
onChange({ widgetOrder: value }, "Paneelvolgorde bijgewerkt");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
<RefreshCw size={13} /> Vernieuwingsinterval
|
||||
</span>
|
||||
<select
|
||||
name={`widgetRefreshInterval-${app.id}`}
|
||||
value={app.widgetRefreshIntervalMs}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetRefreshIntervalMs: Number(event.target.value) },
|
||||
"Vernieuwingsinterval bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{refreshOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section className="widget-config-section" aria-label="Trendgrafiek">
|
||||
<header>
|
||||
<BarChart3 size={14} />
|
||||
<span>
|
||||
<strong>Trendgrafiek</strong>
|
||||
<small>
|
||||
Kies een echte reeks, grafiekstijl en geheugenvenster.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Reeks</span>
|
||||
<select
|
||||
name={`widgetChartMetric-${app.id}`}
|
||||
value={app.widgetChartMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetChartMetric: event.target.value },
|
||||
"Grafiekreeks bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="auto">Beste beschikbare signaal</option>
|
||||
{infrastructureMetrics.map((metric) => (
|
||||
<option key={metric.label} value={metric.label}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
{chartableMetrics.map((metric) => (
|
||||
<option key={metric.label} value={metric.label}>
|
||||
{metric.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Grafiekstijl</span>
|
||||
<select
|
||||
name={`widgetChartType-${app.id}`}
|
||||
value={app.widgetChartType}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
widgetChartType: event.target.value as
|
||||
"line" | "area" | "bar",
|
||||
},
|
||||
"Grafiekstijl bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="line">Lijn</option>
|
||||
<option value="area">Vlak</option>
|
||||
<option value="bar">Balken</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Geschiedenisvenster</span>
|
||||
<select
|
||||
name={`widgetChartSamples-${app.id}`}
|
||||
value={app.widgetChartSamples}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetChartSamples: Number(event.target.value) },
|
||||
"Grafiekvenster bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{chartSamples.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
Laatste {value} metingen
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
app.widgetShowSparkline
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={app.widgetShowSparkline}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{ widgetShowSparkline: !app.widgetShowSparkline },
|
||||
app.widgetShowSparkline
|
||||
? "Grafiek verborgen"
|
||||
: "Grafiek ingeschakeld",
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrendingUp size={15} />
|
||||
<span>
|
||||
<strong>Grafiek tonen</strong>
|
||||
<small>Geschiedenis blijft alleen in het geheugen</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
app.widgetShowLegend
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={app.widgetShowLegend}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{ widgetShowLegend: !app.widgetShowLegend },
|
||||
"Grafieklegenda bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<span>
|
||||
<strong>Legenda tonen</strong>
|
||||
<small>Reeks, waarde en aantal metingen</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="widget-config-section"
|
||||
aria-label="Paneelwaarschuwing"
|
||||
>
|
||||
<header>
|
||||
<Gauge size={14} />
|
||||
<span>
|
||||
<strong>Visuele waarschuwing</strong>
|
||||
<small>
|
||||
Markeer de kaart wanneer een gekozen numeriek signaal een
|
||||
drempelwaarde overschrijdt.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Waarschuwingssignaal</span>
|
||||
<select
|
||||
name={`widgetWarningMetric-${app.id}`}
|
||||
value={app.widgetWarningMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetWarningMetric: event.target.value },
|
||||
"Waarschuwingsmeting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{warningMetrics.map((metric) => (
|
||||
<option value={metric.label} key={metric.label}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Activeringsrichting</span>
|
||||
<select
|
||||
name={`widgetWarningDirection-${app.id}`}
|
||||
value={app.widgetWarningDirection}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
widgetWarningDirection: event.target.value as
|
||||
"above" | "below",
|
||||
},
|
||||
"Waarschuwingsrichting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="above">Op of boven</option>
|
||||
<option value="below">Op of onder</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Drempelwaarde</span>
|
||||
<input
|
||||
name={`widgetWarningThreshold-${app.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1_000_000_000_000}
|
||||
defaultValue={app.widgetWarningThreshold ?? ""}
|
||||
placeholder="Uitgeschakeld"
|
||||
onBlur={(event) => {
|
||||
const parsed = event.target.value
|
||||
? Number(event.target.value)
|
||||
: null;
|
||||
if (parsed !== app.widgetWarningThreshold)
|
||||
onChange(
|
||||
{ widgetWarningThreshold: parsed },
|
||||
"Waarschuwingsdrempel bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="metric-visibility-list"
|
||||
aria-label="Zichtbare app-metingen"
|
||||
>
|
||||
<strong>Appspecifieke metingen</strong>
|
||||
<small>
|
||||
Kies elke statistiek en of die als waarde, meter of voortgangsbalk
|
||||
verschijnt.
|
||||
</small>
|
||||
<div>
|
||||
{availableMetrics.map((metric) => {
|
||||
const visible = !app.widgetHiddenMetrics.includes(metric.label);
|
||||
const style =
|
||||
app.widgetMetricStyles[metric.label] ?? metric.defaultStyle;
|
||||
return (
|
||||
<div className="metric-config-row" key={metric.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={visible ? "active" : ""}
|
||||
aria-pressed={visible}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
widgetHiddenMetrics: visible
|
||||
? [...app.widgetHiddenMetrics, metric.label]
|
||||
: app.widgetHiddenMetrics.filter(
|
||||
(label) => label !== metric.label,
|
||||
),
|
||||
},
|
||||
`${metric.label} ${visible ? "verborgen" : "ingeschakeld"}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{metric.label}</span>
|
||||
<small>{metric.description}</small>
|
||||
</button>
|
||||
<label>
|
||||
<span className="sr-only">Weergave van {metric.label}</span>
|
||||
<select
|
||||
name={`widgetMetricStyle-${app.id}-${metric.label}`}
|
||||
value={style}
|
||||
disabled={!visible}
|
||||
onChange={(event) =>
|
||||
setMetricStyle(
|
||||
metric.label,
|
||||
event.target.value as WidgetMetricStyle,
|
||||
)
|
||||
}
|
||||
aria-label={`Weergave van ${metric.label}`}
|
||||
>
|
||||
<option value="value">Waarde</option>
|
||||
<option value="gauge">Meter</option>
|
||||
<option value="progress">Voortgang</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{orderedMetrics.length > 1 && (
|
||||
<div
|
||||
className="metric-order-list"
|
||||
aria-label="Weergavevolgorde van metingen"
|
||||
>
|
||||
{orderedMetrics.map((metric, index) => (
|
||||
<span key={metric}>
|
||||
<strong>{metric}</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveMetric(index, -1)}
|
||||
aria-label={`${metric} eerder plaatsen`}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === orderedMetrics.length - 1}
|
||||
onClick={() => moveMetric(index, 1)}
|
||||
aria-label={`${metric} later plaatsen`}
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Check, Maximize2, Rows3 } from "lucide-react";
|
||||
import type { WidgetSize } from "../../shared/contracts";
|
||||
|
||||
export interface WidgetSectionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface WidgetConfiguratorProps {
|
||||
label: string;
|
||||
size: WidgetSize;
|
||||
sections: WidgetSectionOption[];
|
||||
onSizeChange: (size: WidgetSize) => void;
|
||||
onSectionChange: (id: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const sizes: Array<{
|
||||
id: WidgetSize;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ id: "compact", label: "Compact", description: "Snelle blik" },
|
||||
{ id: "standard", label: "Standaard", description: "Evenwichtig" },
|
||||
{ id: "wide", label: "Breed", description: "Veel detail" },
|
||||
];
|
||||
|
||||
export function WidgetConfigurator({
|
||||
label,
|
||||
size,
|
||||
sections,
|
||||
onSizeChange,
|
||||
onSectionChange,
|
||||
}: WidgetConfiguratorProps) {
|
||||
return (
|
||||
<div className="widget-configurator">
|
||||
<div className="widget-config-heading">
|
||||
<span>
|
||||
<Maximize2 size={13} /> Paneelformaat
|
||||
</span>
|
||||
<small>
|
||||
{sections.filter((section) => section.enabled).length} content blocks
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
className="widget-size-picker"
|
||||
role="group"
|
||||
aria-label={`Paneelgrootte voor ${label}`}
|
||||
>
|
||||
{sizes.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
className={size === option.id ? "active" : ""}
|
||||
aria-pressed={size === option.id}
|
||||
onClick={() => onSizeChange(option.id)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="widget-content-picker">
|
||||
<legend>
|
||||
<Rows3 size={13} /> Zichtbare inhoud
|
||||
</legend>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
className={section.enabled ? "active" : ""}
|
||||
aria-pressed={section.enabled}
|
||||
onClick={() => onSectionChange(section.id, !section.enabled)}
|
||||
>
|
||||
<span className="widget-content-check" aria-hidden="true">
|
||||
{section.enabled && <Check size={12} />}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{section.label}</strong>
|
||||
<small>{section.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
EyeOff,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import type { WidgetSize } from "../../shared/contracts";
|
||||
|
||||
interface WidgetEditToolbarProps {
|
||||
label: string;
|
||||
size: WidgetSize;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
disabled: boolean;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onSizeChange: (size: WidgetSize) => void;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const sizes: WidgetSize[] = ["compact", "standard", "wide"];
|
||||
|
||||
export function WidgetEditToolbar({
|
||||
label,
|
||||
size,
|
||||
first,
|
||||
last,
|
||||
disabled,
|
||||
onMove,
|
||||
onSizeChange,
|
||||
onHide,
|
||||
}: WidgetEditToolbarProps) {
|
||||
const nextSize =
|
||||
sizes[(sizes.indexOf(size) + 1) % sizes.length] ?? "standard";
|
||||
return (
|
||||
<div
|
||||
className="widget-edit-toolbar"
|
||||
role="toolbar"
|
||||
aria-label={`${label} bewerken`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || first}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label={`${label} eerder plaatsen`}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || last}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label={`${label} later plaatsen`}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSizeChange(nextSize)}
|
||||
aria-label={`${label} wijzigen van ${size} naar ${nextSize}`}
|
||||
>
|
||||
{size === "compact" ? <Maximize2 size={14} /> : <Minimize2 size={14} />}
|
||||
<span>{size}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onHide}
|
||||
aria-label={`${label} verbergen`}
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Download, Layers3, Play, Plus, Trash2, Upload } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useRef } from "react";
|
||||
import type { LayoutPreset, LayoutPresetImport } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
type Performer = (
|
||||
action: () => Promise<unknown>,
|
||||
message: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
interface WidgetPresetManagerProps {
|
||||
presets: LayoutPreset[];
|
||||
perform: Performer;
|
||||
}
|
||||
|
||||
export function WidgetPresetManager({
|
||||
presets,
|
||||
perform,
|
||||
}: WidgetPresetManagerProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const create = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = String(new FormData(form).get("name"));
|
||||
void perform(
|
||||
() => api.createLayoutPreset(name),
|
||||
"Indelingspreset opgeslagen",
|
||||
).then((saved) => saved && form.reset());
|
||||
};
|
||||
const importPreset = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const payload = JSON.parse(await file.text()) as LayoutPresetImport;
|
||||
await perform(
|
||||
() => api.importLayoutPreset(payload),
|
||||
"Indelingspreset geïmporteerd",
|
||||
);
|
||||
} catch {
|
||||
await perform(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new Error("Dit indelingsbestand bevat geen geldige JSON."),
|
||||
),
|
||||
"",
|
||||
);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="widget-presets"
|
||||
aria-labelledby="widget-presets-heading"
|
||||
>
|
||||
<header>
|
||||
<span className="studio-mark">
|
||||
<Layers3 size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong id="widget-presets-heading">Indelingspresets</strong>
|
||||
<small>
|
||||
Wissel in één handeling tussen volledige paneelindelingen.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<form onSubmit={create} className="preset-create-row">
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
maxLength={48}
|
||||
placeholder="Ochtend, Media, Onderhoud…"
|
||||
aria-label="Naam van nieuwe indelingspreset"
|
||||
/>
|
||||
<button type="submit" className="primary-button">
|
||||
<Plus size={15} /> Huidige indeling opslaan
|
||||
</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={15} /> Importeren
|
||||
</button>
|
||||
<input
|
||||
name="layoutPresetFile"
|
||||
className="sr-only"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
onChange={(event) => void importPreset(event)}
|
||||
/>
|
||||
</form>
|
||||
<div className="preset-list">
|
||||
{presets.length === 0 && (
|
||||
<p>
|
||||
Nog geen presets. Sla de huidige indeling op om je eerste te maken.
|
||||
</p>
|
||||
)}
|
||||
{presets.map((preset) => (
|
||||
<article key={preset.id}>
|
||||
<span>
|
||||
<strong>{preset.name}</strong>
|
||||
<small>
|
||||
{preset.configuration.apps.length} app-panelen · bijgewerkt{" "}
|
||||
{new Date(preset.updatedAt).toLocaleDateString()}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
await api.applyLayoutPreset(preset.id);
|
||||
await api.sync();
|
||||
}, `Indeling ${preset.name} toegepast`)
|
||||
}
|
||||
>
|
||||
<Play size={14} /> Toepassen
|
||||
</button>
|
||||
<a
|
||||
href={`/api/layout-presets/${encodeURIComponent(preset.id)}/export`}
|
||||
aria-label={`${preset.name} exporteren`}
|
||||
>
|
||||
<Download size={14} /> Exporteren
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-icon"
|
||||
onClick={() =>
|
||||
void perform(
|
||||
() => api.deleteLayoutPreset(preset.id),
|
||||
`${preset.name} verwijderd`,
|
||||
)
|
||||
}
|
||||
aria-label={`${preset.name} verwijderen`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
WidgetChartType,
|
||||
WidgetValueHistoryPoint,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface WidgetSparklineProps {
|
||||
history: WidgetValueHistoryPoint[];
|
||||
chartType: WidgetChartType;
|
||||
sampleLimit: number;
|
||||
label: string;
|
||||
showLegend: boolean;
|
||||
}
|
||||
|
||||
export function WidgetSparkline({
|
||||
history,
|
||||
chartType,
|
||||
sampleLimit,
|
||||
label,
|
||||
showLegend,
|
||||
}: WidgetSparklineProps) {
|
||||
const samples = history
|
||||
.filter((point) => Number.isFinite(point.value))
|
||||
.slice(-sampleLimit);
|
||||
if (samples.length < 2) return null;
|
||||
const values = samples.map((point) => point.value);
|
||||
const minimum = Math.min(...values);
|
||||
const maximum = Math.max(...values);
|
||||
const range = Math.max(0.001, maximum - minimum);
|
||||
const coordinates = values.map((value, index) => ({
|
||||
x: (index / Math.max(1, values.length - 1)) * 100,
|
||||
y: 28 - ((value - minimum) / range) * 24,
|
||||
}));
|
||||
const points = coordinates
|
||||
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
const barWidth = Math.max(1.5, 82 / values.length);
|
||||
return (
|
||||
<figure
|
||||
className={`widget-sparkline chart-${chartType}`}
|
||||
aria-label={`${label} recent trend`}
|
||||
>
|
||||
<svg viewBox="0 0 100 32" aria-hidden="true">
|
||||
{chartType === "bar" ? (
|
||||
coordinates.map(({ x, y }, index) => (
|
||||
<rect
|
||||
key={`${samples[index]?.sampledAt ?? "sample"}-${index}`}
|
||||
x={Math.max(0, x - barWidth / 2)}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={30 - y}
|
||||
rx="1"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{chartType === "area" && (
|
||||
<polygon points={`0,30 ${points} 100,30`} />
|
||||
)}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{showLegend && (
|
||||
<figcaption>
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{values.at(-1)?.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}{" "}
|
||||
· {values.length} samples
|
||||
</span>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user