Initial curated DockDeck source from 26772208f608782e88c736dc70878e7b5abd3916

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:44:08 +02:00
commit 3c61fba98f
146 files changed
+45229

No files matched your search

+134
View File
@@ -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>
</>
);
}
+447
View File
@@ -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>
)}
</>
);
}
+25
View File
@@ -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>
);
}
+11
View File
@@ -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" />;
}
+559
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+235
View File
@@ -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>
);
}
+101
View File
@@ -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>
</>
);
}
+369
View File
@@ -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;
}
+252
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+557
View File
@@ -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>
);
}
+214
View File
@@ -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
+296
View File
@@ -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>
</>
);
}
+74
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}