generated from Dicken/dickendock
Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState, type DragEvent } from "react";
|
||||
import { useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@launchpad/ui";
|
||||
import type { Service } from "@launchpad/shared";
|
||||
import { useServices } from "../../hooks/useServices.js";
|
||||
import { useCategories } from "../../hooks/useCategories.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
interface ServicePatch {
|
||||
@@ -49,6 +50,57 @@ async function reorderServicesRequest(entries: { id: string; order: number }[])
|
||||
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 = 9;
|
||||
|
||||
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
||||
@@ -81,6 +133,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
@@ -100,12 +153,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||||
<input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(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"
|
||||
/>
|
||||
<CategorySelect value={category} onChange={setCategory} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||||
@@ -185,12 +233,14 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
|
||||
function ServiceRow({
|
||||
service,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isDragging,
|
||||
}: {
|
||||
service: Service;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
@@ -220,7 +270,7 @@ function ServiceRow({
|
||||
|
||||
return (
|
||||
<tr
|
||||
draggable
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
@@ -229,7 +279,10 @@ function ServiceRow({
|
||||
} ${isDragging ? "opacity-40" : ""}`}
|
||||
>
|
||||
<td className="px-2 py-3 text-center">
|
||||
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
|
||||
<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>
|
||||
@@ -303,11 +356,136 @@ function ServiceRow({
|
||||
);
|
||||
}
|
||||
|
||||
type SortColumn = "displayName" | "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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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 reorderMutation = useMutation({
|
||||
mutationFn: reorderServicesRequest,
|
||||
@@ -318,9 +496,46 @@ export function ServicesPage() {
|
||||
onError: () => setLocalOrder(null),
|
||||
});
|
||||
|
||||
const list = localOrder ?? services ?? [];
|
||||
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 "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);
|
||||
}
|
||||
@@ -328,7 +543,7 @@ export function ServicesPage() {
|
||||
function handleDragOver(targetId: string) {
|
||||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
if (!draggedId || draggedId === targetId) return;
|
||||
if (!dragEnabled || !draggedId || draggedId === targetId) return;
|
||||
|
||||
const current = localOrder ?? services ?? [];
|
||||
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
||||
@@ -345,6 +560,7 @@ export function ServicesPage() {
|
||||
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 })));
|
||||
@@ -357,11 +573,21 @@ export function ServicesPage() {
|
||||
title="Dienste"
|
||||
description={
|
||||
hiddenCount > 0
|
||||
? `Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
||||
: "Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||
? `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."
|
||||
}
|
||||
/>
|
||||
|
||||
<ImportExportBar />
|
||||
|
||||
{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 ? (
|
||||
@@ -375,11 +601,11 @@ export function ServicesPage() {
|
||||
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" />
|
||||
<th className="px-4 py-2 font-medium">Dienst</th>
|
||||
<th className="px-4 py-2 font-medium">Kategorie</th>
|
||||
<th className="px-4 py-2 font-medium">Alias</th>
|
||||
<th className="px-4 py-2 font-medium">Port</th>
|
||||
<th className="px-4 py-2 font-medium">Protokoll</th>
|
||||
<SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||
<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>
|
||||
@@ -389,6 +615,7 @@ export function ServicesPage() {
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
draggable={dragEnabled}
|
||||
isDragging={draggedId === service.id}
|
||||
onDragStart={handleDragStart(service.id)}
|
||||
onDragOver={handleDragOver(service.id)}
|
||||
@@ -402,7 +629,7 @@ export function ServicesPage() {
|
||||
) : (
|
||||
<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.
|
||||
zu finden, oder importiere eine Liste oben.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user