generated from Dicken/dickendock
round15: manuelle Aenderungen nicht mehr nachfragen, 7-Tage-Schwelle fuer verschwundene Geraete, Icon-Buttons mit Tooltip, fixierte mobile Kopfleiste, Scroll-to-top-Button
This commit is contained in:
@@ -25,6 +25,8 @@ function mapRow(row: typeof services.$inferSelect): Service {
|
|||||||
alias: JSON.parse(row.alias) as string[],
|
alias: JSON.parse(row.alias) as string[],
|
||||||
order: row.order,
|
order: row.order,
|
||||||
visible: row.visible,
|
visible: row.visible,
|
||||||
|
displayNameEditedManually: row.displayNameEditedManually,
|
||||||
|
categoryEditedManually: row.categoryEditedManually,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +223,13 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null {
|
|||||||
* SPA, deren <title> nur gelegentlich rechtzeitig geladen ist), ein
|
* SPA, deren <title> nur gelegentlich rechtzeitig geladen ist), ein
|
||||||
* einmaliger Ausrutscher soll nicht sofort als Änderung gemeldet werden.
|
* einmaliger Ausrutscher soll nicht sofort als Änderung gemeldet werden.
|
||||||
*
|
*
|
||||||
|
* Wurde ein Feld bereits manuell über die Admin-UI gesetzt
|
||||||
|
* (displayNameEditedManually/categoryEditedManually), wird ein abweichender
|
||||||
|
* Scan-Vorschlag NIE als nameChange gemeldet - der Nutzer hat sich bewusst
|
||||||
|
* für seinen eigenen Wert entschieden, ein erneutes Nachfragen bei jedem
|
||||||
|
* Scan wäre nur nervig. Die Admin-UI zeigt stattdessen dauerhaft ein
|
||||||
|
* Hinweis-Icon am Dienst (siehe ServicesPage.tsx).
|
||||||
|
*
|
||||||
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
||||||
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
||||||
*/
|
*/
|
||||||
@@ -248,26 +257,28 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
if (
|
if (
|
||||||
input.suggestedDisplayName &&
|
input.suggestedDisplayName &&
|
||||||
input.suggestedDisplayName !== existing.displayName &&
|
input.suggestedDisplayName !== existing.displayName &&
|
||||||
input.suggestedDisplayName === existingRow.lastSuggestedDisplayName
|
input.suggestedDisplayName === existingRow.lastSuggestedDisplayName &&
|
||||||
|
!existingRow.displayNameEditedManually
|
||||||
) {
|
) {
|
||||||
nameChanges.push({
|
nameChanges.push({
|
||||||
field: "displayName",
|
field: "displayName",
|
||||||
current: existing.displayName,
|
current: existing.displayName,
|
||||||
suggested: input.suggestedDisplayName,
|
suggested: input.suggestedDisplayName,
|
||||||
manualOverride: existingRow.displayNameEditedManually,
|
manualOverride: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.suggestedCategory !== undefined &&
|
input.suggestedCategory !== undefined &&
|
||||||
(input.suggestedCategory ?? null) !== existing.category &&
|
(input.suggestedCategory ?? null) !== existing.category &&
|
||||||
input.suggestedCategory &&
|
input.suggestedCategory &&
|
||||||
input.suggestedCategory === existingRow.lastSuggestedCategory
|
input.suggestedCategory === existingRow.lastSuggestedCategory &&
|
||||||
|
!existingRow.categoryEditedManually
|
||||||
) {
|
) {
|
||||||
nameChanges.push({
|
nameChanges.push({
|
||||||
field: "category",
|
field: "category",
|
||||||
current: existing.category,
|
current: existing.category,
|
||||||
suggested: input.suggestedCategory,
|
suggested: input.suggestedCategory,
|
||||||
manualOverride: existingRow.categoryEditedManually,
|
manualOverride: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,10 +208,21 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||||
// mehr in der Liste sind – nicht automatisch gelöscht, nur zur
|
// mehr in der Liste sind – werden erst nach mindestens 7 Tagen
|
||||||
// manuellen Durchsicht zurückgegeben.
|
// Abwesenheit zur Durchsicht vorgeschlagen (nicht schon beim ersten
|
||||||
|
// verpassten Scan), damit ein kurzzeitig offline/im Standby befindliches
|
||||||
|
// Gerät (Reboot, WLAN-Aussetzer, Nacht-Standby) nicht sofort zum
|
||||||
|
// Löschen vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert
|
||||||
|
// (siehe upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither
|
||||||
|
// nicht mehr da.
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
const foundIps = new Set(hosts.map((h) => h.ip));
|
const foundIps = new Set(hosts.map((h) => h.ip));
|
||||||
const staleDevices = devicesBeforeScan.filter((d) => !foundIps.has(d.ip));
|
const staleDevices = devicesBeforeScan.filter((d) => {
|
||||||
|
if (foundIps.has(d.ip)) return false;
|
||||||
|
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
|
||||||
|
return now - lastSeen >= SEVEN_DAYS_MS;
|
||||||
|
});
|
||||||
|
|
||||||
logRepo.logScan({
|
logRepo.logScan({
|
||||||
type: "fritzbox",
|
type: "fritzbox",
|
||||||
|
|||||||
35
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
35
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schwebender Button unten rechts, der zum Seitenanfang zurückscrollt.
|
||||||
|
* Erscheint erst, sobald tatsächlich runtergescrollt wurde (kein Sinn, ihn
|
||||||
|
* direkt am Anfang der Seite schon anzuzeigen).
|
||||||
|
*/
|
||||||
|
export function ScrollToTopButton() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onScroll() {
|
||||||
|
setVisible(window.scrollY > 300);
|
||||||
|
}
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
title="Nach oben scrollen"
|
||||||
|
aria-label="Nach oben scrollen"
|
||||||
|
className="fixed bottom-5 right-5 z-30 flex h-11 w-11 items-center justify-center rounded-full
|
||||||
|
border border-black/10 bg-white/90 text-lg text-black/70 shadow-lg backdrop-blur-md
|
||||||
|
transition-opacity hover:bg-white dark:border-white/10 dark:bg-neutral-900/90
|
||||||
|
dark:text-white/70 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
⬆️
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
import { StatusBadge } from "@launchpad/ui";
|
import { StatusBadge } from "@launchpad/ui";
|
||||||
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||||
|
import { ScrollToTopButton } from "../../components/ScrollToTopButton.js";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/", label: "Startseite", icon: "🏠" },
|
{ to: "/", label: "Startseite", icon: "🏠" },
|
||||||
@@ -30,10 +31,13 @@ export function AdminLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints sichtbar */}
|
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints
|
||||||
|
sichtbar. Fixiert am oberen Rand, damit sie bei langen Seiten (z. B.
|
||||||
|
Dienste-Tabelle) beim Scrollen sichtbar bleibt statt mit dem Inhalt
|
||||||
|
mitzuwandern - main hat dafür unten zusätzliches pt-16 auf Mobil. */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 border-b border-black/10 bg-white/80 px-4 py-3
|
className="fixed inset-x-0 top-0 z-20 flex items-center gap-3 border-b border-black/10
|
||||||
backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden"
|
bg-white/80 px-4 py-3 backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => setMobileOpen(true)}
|
||||||
@@ -114,10 +118,14 @@ export function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* sm:ml-56 gleicht die jetzt fixierte Sidebar aus (statt Flexbox-Nebeneinander) */}
|
{/* pt-16 auf Mobil gleicht die jetzt fixierte Kopfleiste aus (sonst
|
||||||
<main className="overflow-x-hidden p-4 sm:ml-56 sm:p-8">
|
würde der Seiteninhalt anfangs darunter verschwinden);
|
||||||
|
sm:ml-56 gleicht die fixierte Sidebar aus (statt Flexbox-Nebeneinander) */}
|
||||||
|
<main className="overflow-x-hidden p-4 pt-16 sm:ml-56 sm:p-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ScrollToTopButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,12 +260,8 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button>
|
||||||
Speichern
|
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
{mutation.isError ? (
|
{mutation.isError ? (
|
||||||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -358,12 +354,8 @@ function BookmarkRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button size="sm" onClick={() => setEditing(true)}>
|
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button>
|
||||||
Bearbeiten
|
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -125,22 +125,18 @@ function CategoryRow({
|
|||||||
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
className="flex-1 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"
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending}>
|
<Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern">💾</Button>
|
||||||
Speichern
|
<Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen">
|
||||||
</Button>
|
✕
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
|
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
|
||||||
<Button size="sm" onClick={() => setEditing(true)}>
|
<Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen">
|
||||||
Umbenennen
|
✏️
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -162,12 +162,8 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button>
|
||||||
Speichern
|
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -203,59 +199,36 @@ function NameChangesReview({
|
|||||||
|
|
||||||
const fieldLabel = (field: ScanNameChange["field"]) =>
|
const fieldLabel = (field: ScanNameChange["field"]) =>
|
||||||
field === "displayName" ? "Name" : "Kategorie";
|
field === "displayName" ? "Name" : "Kategorie";
|
||||||
const manual = remaining.filter((c) => c.manualOverride);
|
|
||||||
const real = remaining.filter((c) => !c.manualOverride);
|
|
||||||
|
|
||||||
function renderList(list: ScanNameChange[]) {
|
return (
|
||||||
return (
|
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{list.map((c) => (
|
{remaining.map((c) => (
|
||||||
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
||||||
>
|
title="Behalten (Vorschlag verwerfen)"
|
||||||
Behalten
|
aria-label="Behalten (Vorschlag verwerfen)"
|
||||||
</Button>
|
>✕</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => applyMutation.mutate(c)}
|
onClick={() => applyMutation.mutate(c)}
|
||||||
disabled={applyMutation.isPending}
|
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button>
|
||||||
>
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{real.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
|
||||||
{real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
|
||||||
</p>
|
|
||||||
{renderList(real)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{manual.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-black/10 bg-black/[0.02] p-3 text-xs dark:border-white/10 dark:bg-white/[0.02]">
|
|
||||||
<p className="mb-2 font-medium text-black/60 dark:text-white/60">
|
|
||||||
{manual.length} Abweichung(en), weil du diesen Wert bereits manuell angepasst hast:
|
|
||||||
</p>
|
|
||||||
{renderList(manual)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button onClick={onDone} className="text-black/40 underline dark:text-white/40">
|
|
||||||
Hinweis schließen
|
Hinweis schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,12 +261,8 @@ function DeviceNameSuggestionBanner({
|
|||||||
Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
|
Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
<Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
<Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,17 +296,12 @@ function StaleServicesReview({ staleServices, onDone }: { staleServices: Service
|
|||||||
{s.displayName} ({s.hostname}:{s.port})
|
{s.displayName} ({s.hostname}:{s.port})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))}>
|
<Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteMutation.mutate(s.id)}
|
onClick={() => deleteMutation.mutate(s.id)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button>
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -424,9 +388,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button size="sm" onClick={() => setEditing(true)}>
|
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button>
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -438,13 +400,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteMutation.mutate()}
|
onClick={() => deleteMutation.mutate()}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -77,17 +77,13 @@ function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button>
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={mutation.isPending || !displayName.trim() || !url.trim()}
|
disabled={mutation.isPending || !displayName.trim() || !url.trim()}
|
||||||
>
|
>💾</Button>
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{mutation.isError ? (
|
{mutation.isError ? (
|
||||||
@@ -140,9 +136,7 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) {
|
|||||||
<td className="px-4 py-3 text-xs text-black/50 dark:text-white/50">{formatDate(item.savedAt)}</td>
|
<td className="px-4 py-3 text-xs text-black/50 dark:text-white/50">{formatDate(item.savedAt)}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
<Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button>
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -153,13 +147,13 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) {
|
|||||||
🔖 Zu Lesezeichen
|
🔖 Zu Lesezeichen
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteMutation.mutate()}
|
onClick={() => deleteMutation.mutate()}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
title="Löschen"
|
||||||
Löschen
|
aria-label="Löschen"
|
||||||
</Button>
|
>🗑️</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -127,57 +127,30 @@ function NameChangesReview({
|
|||||||
if (nameChanges.length === 0) return null;
|
if (nameChanges.length === 0) return null;
|
||||||
|
|
||||||
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
||||||
const manual = nameChanges.filter((c) => c.manualOverride);
|
|
||||||
const real = nameChanges.filter((c) => !c.manualOverride);
|
|
||||||
|
|
||||||
function renderList(list: ScanNameChange[]) {
|
return (
|
||||||
return (
|
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{nameChanges.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{list.map((c) => (
|
{nameChanges.map((c) => (
|
||||||
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
|
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „
|
||||||
{c.suggested}"
|
{c.suggested}"
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => onResolve(nameChangeKey(c))}>
|
<Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => applyMutation.mutate(c)}
|
onClick={() => applyMutation.mutate(c)}
|
||||||
disabled={applyMutation.isPending}
|
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button>
|
||||||
>
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
{real.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
|
||||||
{real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
|
||||||
</p>
|
|
||||||
{renderList(real)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{manual.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-black/10 bg-black/[0.02] p-3 text-xs dark:border-white/10 dark:bg-white/[0.02]">
|
|
||||||
<p className="mb-2 font-medium text-black/60 dark:text-white/60">
|
|
||||||
{manual.length} Abweichung(en), weil du diesen Wert bereits manuell angepasst hast – der
|
|
||||||
Scan schlägt lediglich erneut den automatisch erkannten Wert vor, es hat sich nichts am
|
|
||||||
Dienst selbst geändert:
|
|
||||||
</p>
|
|
||||||
{renderList(manual)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,17 +187,12 @@ function StaleServicesReview({
|
|||||||
{s.displayName} ({s.hostname}:{s.port})
|
{s.displayName} ({s.hostname}:{s.port})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => onResolve(s.id)}>
|
<Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteMutation.mutate(s.id)}
|
onClick={() => deleteMutation.mutate(s.id)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button>
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -266,17 +234,12 @@ function DeviceChangesReview({
|
|||||||
{c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
|
{c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)}>
|
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => applyMutation.mutate(c)}
|
onClick={() => applyMutation.mutate(c)}
|
||||||
disabled={applyMutation.isPending}
|
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button>
|
||||||
>
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -317,17 +280,12 @@ function StaleDevicesReview({
|
|||||||
{d.hostname} ({d.ip})
|
{d.hostname} ({d.ip})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => onResolve(d.id)}>
|
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button>
|
||||||
Behalten
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => deleteMutation.mutate(d.id)}
|
onClick={() => deleteMutation.mutate(d.id)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -229,12 +229,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full items-center gap-2 pt-1">
|
<div className="flex w-full items-center gap-2 pt-1">
|
||||||
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button>
|
||||||
Speichern
|
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
{mutation.isError ? (
|
{mutation.isError ? (
|
||||||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -330,6 +326,11 @@ function ServiceRow({
|
|||||||
<Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
|
<Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
|
||||||
|
{service.displayNameEditedManually ? (
|
||||||
|
<span title="Name wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
||||||
|
✍️
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{!service.visible ? (
|
{!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">
|
<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
|
ausgeblendet
|
||||||
@@ -353,7 +354,16 @@ function ServiceRow({
|
|||||||
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
|
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
|
||||||
{deviceIp ?? "–"}
|
{deviceIp ?? "–"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{service.category ?? "–"}
|
||||||
|
{service.categoryEditedManually ? (
|
||||||
|
<span title="Kategorie wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt">
|
||||||
|
✍️
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
||||||
{deviceMac ?? "–"}
|
{deviceMac ?? "–"}
|
||||||
</td>
|
</td>
|
||||||
@@ -384,12 +394,8 @@ function ServiceRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button size="sm" onClick={() => setEditing(true)}>
|
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button>
|
||||||
Bearbeiten
|
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export interface Service {
|
|||||||
order: number;
|
order: number;
|
||||||
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
|
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
/**
|
||||||
|
* true, wenn Name/Kategorie zuletzt manuell über die Admin-UI gesetzt
|
||||||
|
* wurden (statt vom letzten Scan-Vorschlag übernommen). Ein Scan schlägt
|
||||||
|
* dann zwar intern weiterhin den automatisch erkannten Wert vor, meldet
|
||||||
|
* das aber NICHT mehr als zu bestätigende Änderung (siehe
|
||||||
|
* upsertServiceFromScan) - stattdessen zeigt die Admin-UI ein Hinweis-Icon.
|
||||||
|
*/
|
||||||
|
displayNameEditedManually: boolean;
|
||||||
|
categoryEditedManually: boolean;
|
||||||
/**
|
/**
|
||||||
* Nicht vom Backend geliefert, sondern optional vom Frontend ergänzt (aus
|
* Nicht vom Backend geliefert, sondern optional vom Frontend ergänzt (aus
|
||||||
* den bekannten Geräten), damit die Suche auch über den sprechenden
|
* den bekannten Geräten), damit die Suche auch über den sprechenden
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { forwardRef, type ButtonHTMLAttributes } from "react";
|
|||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
size?: "sm" | "md";
|
size?: "sm" | "md" | "icon";
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||||
@@ -18,6 +18,9 @@ const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
|||||||
const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = {
|
const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||||
sm: "px-3 py-1.5 text-sm",
|
sm: "px-3 py-1.5 text-sm",
|
||||||
md: "px-4 py-2 text-sm",
|
md: "px-4 py-2 text-sm",
|
||||||
|
// Für Icon-only-Buttons (Zeilenaktionen in Tabellen) - immer mit title=""
|
||||||
|
// für ein natives Browser-Tooltip verwenden, da kein sichtbarer Text da ist.
|
||||||
|
icon: "h-8 w-8 p-0 text-base leading-none",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
|||||||
Reference in New Issue
Block a user