Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung

This commit is contained in:
2026-07-20 07:52:38 +02:00
parent dc91a9aba9
commit 96cf29fef4
37 changed files with 1988 additions and 494 deletions

View File

@@ -1,9 +1,10 @@
import { useMemo, useRef, useState, type DragEvent } from "react";
import { useMemo, useState, type DragEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
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 {
@@ -101,7 +102,7 @@ function CategorySelect({
);
}
const EDIT_FORM_COLSPAN = 9;
const EDIT_FORM_COLSPAN = 11;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient();
@@ -233,6 +234,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
function ServiceRow({
service,
deviceMac,
draggable,
onDragStart,
onDragOver,
@@ -240,6 +242,7 @@ function ServiceRow({
isDragging,
}: {
service: Service;
deviceMac: string | null;
draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
@@ -307,16 +310,24 @@ function ServiceRow({
</td>
<td className="px-4 py-3">
<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}
<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>
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</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>
@@ -356,7 +367,7 @@ function ServiceRow({
);
}
type SortColumn = "displayName" | "category" | "alias" | "port" | "https" | null;
type SortColumn = "displayName" | "hostname" | "category" | "alias" | "port" | "https" | null;
function SortableHeader({
label,
@@ -387,106 +398,21 @@ function SortableHeader({
);
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(",")[1] ?? "");
};
reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden"));
reader.readAsDataURL(file);
});
}
function detectFormat(filename: string): "csv" | "xlsx" | "json" {
if (filename.toLowerCase().endsWith(".xlsx")) return "xlsx";
if (filename.toLowerCase().endsWith(".json")) return "json";
return "csv";
}
interface ImportResult {
imported: number;
skipped: number;
errors: string[];
}
function ImportExportBar() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const importMutation = useMutation({
mutationFn: async (file: File): Promise<ImportResult> => {
const content = await readFileAsBase64(file);
const format = detectFormat(file.name);
const res = await fetch("/api/services/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format, content }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`);
}
return res.json();
},
onSuccess: (data) => {
setResult(data);
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
onError: (err: Error) => setResult({ imported: 0, skipped: 0, errors: [err.message] }),
});
return (
<div className="mb-6 flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-black/50 dark:text-white/50">Export:</span>
<a href="/api/services/export?format=csv" download>
<Button size="sm">CSV</Button>
</a>
<a href="/api/services/export?format=xlsx" download>
<Button size="sm">Excel</Button>
</a>
<a href="/api/services/export?format=json" download>
<Button size="sm">JSON</Button>
</a>
<span className="ml-4 text-xs font-medium text-black/50 dark:text-white/50">Import:</span>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => fileInputRef.current?.click()} disabled={importMutation.isPending}>
{importMutation.isPending ? "Importiere …" : "Datei wählen (CSV/Excel/JSON)"}
</Button>
{result ? (
<span className="w-full text-xs text-black/50 dark:text-white/50">
{result.imported} importiert, {result.skipped} übersprungen (bereits vorhanden)
{result.errors.length > 0 ? `, ${result.errors.length} Fehler: ${result.errors.join(" | ")}` : "."}
</span>
) : null}
</div>
);
}
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: () => {
@@ -507,6 +433,9 @@ export function ServicesPage() {
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;
@@ -578,8 +507,6 @@ export function ServicesPage() {
}
/>
<ImportExportBar />
{sortColumn ? (
<div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
@@ -595,14 +522,16 @@ export function ServicesPage() {
) : 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-[860px] text-sm">
<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} />
@@ -615,6 +544,7 @@ export function ServicesPage() {
<ServiceRow
key={service.id}
service={service}
deviceMac={macByDeviceId[service.deviceId] ?? null}
draggable={dragEnabled}
isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)}