generated from Dicken/dickendock
568 lines
20 KiB
TypeScript
568 lines
20 KiB
TypeScript
import { useMemo, useState, type DragEvent } from "react";
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { Button, Favicon } from "@launchpad/ui";
|
||
import type { Service } from "@launchpad/shared";
|
||
import { useServices } from "../../hooks/useServices.js";
|
||
import { useCategories } from "../../hooks/useCategories.js";
|
||
import { useDevices } from "../../hooks/useDevices.js";
|
||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||
|
||
interface ServicePatch {
|
||
displayName?: string;
|
||
category?: string | null;
|
||
alias?: string[];
|
||
order?: number;
|
||
favorite?: boolean;
|
||
visible?: boolean;
|
||
hostname?: string;
|
||
port?: number;
|
||
https?: boolean;
|
||
url?: string;
|
||
}
|
||
|
||
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
|
||
const res = await fetch(`/api/services/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function deleteServiceRequest(id: string) {
|
||
const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
|
||
if (!res.ok && res.status !== 404) {
|
||
throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||
}
|
||
}
|
||
|
||
async function reorderServicesRequest(entries: { id: string; order: number }[]) {
|
||
const res = await fetch("/api/services/reorder", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(entries),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
const NEW_CATEGORY_VALUE = "__new__";
|
||
|
||
function CategorySelect({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const { data: categories } = useCategories();
|
||
const isKnown = !value || categories?.some((c) => c.name === value);
|
||
const [isNew, setIsNew] = useState(!isKnown);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<select
|
||
value={isNew ? NEW_CATEGORY_VALUE : value}
|
||
onChange={(e) => {
|
||
if (e.target.value === NEW_CATEGORY_VALUE) {
|
||
setIsNew(true);
|
||
onChange("");
|
||
} else {
|
||
setIsNew(false);
|
||
onChange(e.target.value);
|
||
}
|
||
}}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
>
|
||
<option value="">– Keine –</option>
|
||
{categories?.map((c) => (
|
||
<option key={c.id} value={c.name}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie …</option>
|
||
</select>
|
||
{isNew ? (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder="Name der neuen Kategorie"
|
||
autoFocus
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const EDIT_FORM_COLSPAN = 11;
|
||
|
||
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
||
const queryClient = useQueryClient();
|
||
const [displayName, setDisplayName] = useState(service.displayName);
|
||
const [category, setCategory] = useState(service.category ?? "");
|
||
const [alias, setAlias] = useState(service.alias.join(", "));
|
||
const [order, setOrder] = useState(String(service.order));
|
||
const [hostname, setHostname] = useState(service.hostname);
|
||
const [port, setPort] = useState(String(service.port));
|
||
const [https, setHttps] = useState(service.https);
|
||
|
||
const portNumber = Number(port) || service.port;
|
||
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: () =>
|
||
patchService(service.id, {
|
||
displayName: displayName.trim(),
|
||
category: category.trim() || null,
|
||
alias: alias
|
||
.split(",")
|
||
.map((a) => a.trim())
|
||
.filter(Boolean),
|
||
order: Number(order) || 0,
|
||
hostname: hostname.trim(),
|
||
port: portNumber,
|
||
https,
|
||
url: previewUrl,
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||
onDone();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||
<CategorySelect value={category} onChange={setCategory} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Alias (kommagetrennt)
|
||
</label>
|
||
<input
|
||
value={alias}
|
||
onChange={(e) => setAlias(e.target.value)}
|
||
className="w-48 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Reihenfolge</label>
|
||
<input
|
||
value={order}
|
||
onChange={(e) => setOrder(e.target.value)}
|
||
type="number"
|
||
className="w-20 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
|
||
<div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Hostname / IP
|
||
</label>
|
||
<input
|
||
value={hostname}
|
||
onChange={(e) => setHostname(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Port</label>
|
||
<input
|
||
value={port}
|
||
onChange={(e) => setPort(e.target.value)}
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
className="w-24 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 flex items-center gap-1.5 text-xs text-black/50 dark:text-white/50">
|
||
<input
|
||
type="checkbox"
|
||
checked={https}
|
||
onChange={(e) => setHttps(e.target.checked)}
|
||
className="rounded"
|
||
/>
|
||
HTTPS
|
||
</label>
|
||
</div>
|
||
<div className="text-xs text-black/40 dark:text-white/40">
|
||
Öffnet: <span className="font-mono">{previewUrl}</span>
|
||
</div>
|
||
|
||
<div className="flex w-full gap-2 pt-1">
|
||
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||
Speichern
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={onDone}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function ServiceRow({
|
||
service,
|
||
deviceMac,
|
||
draggable,
|
||
onDragStart,
|
||
onDragOver,
|
||
onDrop,
|
||
isDragging,
|
||
}: {
|
||
service: Service;
|
||
deviceMac: string | null;
|
||
draggable: boolean;
|
||
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
isDragging: boolean;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
const favoriteMutation = useMutation({
|
||
mutationFn: () => patchService(service.id, { favorite: !service.favorite }),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||
});
|
||
|
||
const visibleMutation = useMutation({
|
||
mutationFn: () => patchService(service.id, { visible: !service.visible }),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: () => deleteServiceRequest(service.id),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||
});
|
||
|
||
if (editing) {
|
||
return <EditForm service={service} onDone={() => setEditing(false)} />;
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
draggable={draggable}
|
||
onDragStart={onDragStart}
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
|
||
service.visible ? "" : "opacity-50"
|
||
} ${isDragging ? "opacity-40" : ""}`}
|
||
>
|
||
<td className="px-2 py-3 text-center">
|
||
<span
|
||
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
|
||
aria-hidden
|
||
>
|
||
⠿⠿
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
onClick={() => favoriteMutation.mutate()}
|
||
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||
>
|
||
★
|
||
</button>
|
||
<button
|
||
onClick={() => visibleMutation.mutate()}
|
||
aria-label={service.visible ? "In der Suche ausblenden" : "In der Suche einblenden"}
|
||
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
|
||
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
|
||
>
|
||
{service.visible ? "👁️" : "🙈"}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
||
{!service.visible ? (
|
||
<span className="rounded-full bg-black/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/50 dark:bg-white/10 dark:text-white/50">
|
||
ausgeblendet
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
|
||
{service.hostname}
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
||
{deviceMac ?? "–"}
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||
service.https
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
|
||
}`}
|
||
>
|
||
{service.https ? "https" : "http"}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<a
|
||
href={service.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||
>
|
||
öffnen
|
||
</a>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button size="sm" onClick={() => setEditing(true)}>
|
||
Bearbeiten
|
||
</Button>
|
||
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
||
Löschen
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
type SortColumn = "displayName" | "hostname" | "category" | "alias" | "port" | "https" | null;
|
||
|
||
function SortableHeader({
|
||
label,
|
||
column,
|
||
activeColumn,
|
||
direction,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
column: SortColumn;
|
||
activeColumn: SortColumn;
|
||
direction: "asc" | "desc";
|
||
onClick: (column: SortColumn) => void;
|
||
}) {
|
||
const active = activeColumn === column;
|
||
return (
|
||
<th className="px-4 py-2 font-medium">
|
||
<button
|
||
onClick={() => onClick(column)}
|
||
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||
active ? "text-black dark:text-white" : ""
|
||
}`}
|
||
>
|
||
{label}
|
||
<span className="text-[10px]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</span>
|
||
</button>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
export function ServicesPage() {
|
||
const { data: services, isLoading, isError } = useServices();
|
||
const { data: devices } = useDevices();
|
||
const queryClient = useQueryClient();
|
||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
||
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||
|
||
const macByDeviceId = useMemo(() => {
|
||
const map: Record<string, string | null> = {};
|
||
for (const d of devices ?? []) map[d.id] = d.mac;
|
||
return map;
|
||
}, [devices]);
|
||
|
||
const reorderMutation = useMutation({
|
||
mutationFn: reorderServicesRequest,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||
setLocalOrder(null);
|
||
},
|
||
onError: () => setLocalOrder(null),
|
||
});
|
||
|
||
const baseList = localOrder ?? services ?? [];
|
||
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
||
|
||
const list = useMemo(() => {
|
||
if (!sortColumn) return baseList;
|
||
const sorted = [...baseList].sort((a, b) => {
|
||
let cmp = 0;
|
||
switch (sortColumn) {
|
||
case "displayName":
|
||
cmp = a.displayName.localeCompare(b.displayName);
|
||
break;
|
||
case "hostname":
|
||
cmp = a.hostname.localeCompare(b.hostname);
|
||
break;
|
||
case "category":
|
||
cmp = (a.category ?? "").localeCompare(b.category ?? "");
|
||
break;
|
||
case "alias":
|
||
cmp = a.alias.join(",").localeCompare(b.alias.join(","));
|
||
break;
|
||
case "port":
|
||
cmp = a.port - b.port;
|
||
break;
|
||
case "https":
|
||
cmp = Number(a.https) - Number(b.https);
|
||
break;
|
||
}
|
||
return sortDirection === "asc" ? cmp : -cmp;
|
||
});
|
||
return sorted;
|
||
}, [baseList, sortColumn, sortDirection]);
|
||
|
||
function handleHeaderClick(column: SortColumn) {
|
||
if (sortColumn === column) {
|
||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||
} else {
|
||
setSortColumn(column);
|
||
setSortDirection("asc");
|
||
}
|
||
}
|
||
|
||
const dragEnabled = sortColumn === null;
|
||
|
||
function handleDragStart(id: string) {
|
||
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
|
||
}
|
||
|
||
function handleDragOver(targetId: string) {
|
||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||
e.preventDefault();
|
||
if (!dragEnabled || !draggedId || draggedId === targetId) return;
|
||
|
||
const current = localOrder ?? services ?? [];
|
||
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
||
const toIndex = current.findIndex((s) => s.id === targetId);
|
||
if (fromIndex === -1 || toIndex === -1) return;
|
||
|
||
const next = [...current];
|
||
const [moved] = next.splice(fromIndex, 1);
|
||
next.splice(toIndex, 0, moved);
|
||
setLocalOrder(next);
|
||
};
|
||
}
|
||
|
||
function handleDrop() {
|
||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||
e.preventDefault();
|
||
if (!dragEnabled) return;
|
||
setDraggedId(null);
|
||
const current = localOrder ?? services ?? [];
|
||
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
|
||
};
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<AdminPageHeader
|
||
title="Dienste"
|
||
description={
|
||
hiddenCount > 0
|
||
? `Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
||
: "Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||
}
|
||
/>
|
||
|
||
{sortColumn ? (
|
||
<div className="mb-3">
|
||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||
← Zur manuellen Reihenfolge (Drag & Drop) zurück
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
||
) : isError ? (
|
||
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
||
) : list.length > 0 ? (
|
||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[1020px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||
<th className="px-2 py-2" />
|
||
<th className="px-2 py-2" />
|
||
<SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Host/IP" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2 font-medium">MAC</th>
|
||
<SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Port" column="port" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Protokoll" column="https" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2 font-medium">URL</th>
|
||
<th className="px-4 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{list.map((service) => (
|
||
<ServiceRow
|
||
key={service.id}
|
||
service={service}
|
||
deviceMac={macByDeviceId[service.deviceId] ?? null}
|
||
draggable={dragEnabled}
|
||
isDragging={draggedId === service.id}
|
||
onDragStart={handleDragStart(service.id)}
|
||
onDragOver={handleDragOver(service.id)}
|
||
onDrop={handleDrop()}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-black/40 dark:text-white/40">
|
||
Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche
|
||
zu finden, oder importiere eine Liste oben.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|