From 7035d4ebdaaca28eb6966b80582e3ca78ade905e Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 23 Jul 2026 12:32:19 +0200 Subject: [PATCH] round15: manuelle Aenderungen nicht mehr nachfragen, 7-Tage-Schwelle fuer verschwundene Geraete, Icon-Buttons mit Tooltip, fixierte mobile Kopfleiste, Scroll-to-top-Button --- apps/backend/src/db/repositories/services.ts | 19 +++- apps/backend/src/routes/scan.ts | 17 +++- .../src/components/ScrollToTopButton.tsx | 35 ++++++++ .../frontend/src/routes/admin/AdminLayout.tsx | 18 ++-- .../src/routes/admin/BookmarksPage.tsx | 16 +--- .../src/routes/admin/CategoriesPage.tsx | 16 ++-- .../frontend/src/routes/admin/DevicesPage.tsx | 87 +++++-------------- .../src/routes/admin/ReadLaterPage.tsx | 22 ++--- .../frontend/src/routes/admin/ScannerPage.tsx | 78 ++++------------- .../src/routes/admin/ServicesPage.tsx | 32 ++++--- packages/shared/src/index.ts | 9 ++ packages/ui/src/Button.tsx | 5 +- 12 files changed, 168 insertions(+), 186 deletions(-) create mode 100644 apps/frontend/src/components/ScrollToTopButton.tsx diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 1681a2d..a173ad0 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -25,6 +25,8 @@ function mapRow(row: typeof services.$inferSelect): Service { alias: JSON.parse(row.alias) as string[], order: row.order, visible: row.visible, + displayNameEditedManually: row.displayNameEditedManually, + categoryEditedManually: row.categoryEditedManually, }; } @@ -221,6 +223,13 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null { * SPA, deren nur gelegentlich rechtzeitig geladen ist), ein * 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 * bestimmen den Anfangswert NUR beim erstmaligen Anlegen. */ @@ -248,26 +257,28 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult if ( input.suggestedDisplayName && input.suggestedDisplayName !== existing.displayName && - input.suggestedDisplayName === existingRow.lastSuggestedDisplayName + input.suggestedDisplayName === existingRow.lastSuggestedDisplayName && + !existingRow.displayNameEditedManually ) { nameChanges.push({ field: "displayName", current: existing.displayName, suggested: input.suggestedDisplayName, - manualOverride: existingRow.displayNameEditedManually, + manualOverride: false, }); } if ( input.suggestedCategory !== undefined && (input.suggestedCategory ?? null) !== existing.category && input.suggestedCategory && - input.suggestedCategory === existingRow.lastSuggestedCategory + input.suggestedCategory === existingRow.lastSuggestedCategory && + !existingRow.categoryEditedManually ) { nameChanges.push({ field: "category", current: existing.category, suggested: input.suggestedCategory, - manualOverride: existingRow.categoryEditedManually, + manualOverride: false, }); } diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 37062e3..15c5e45 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -208,10 +208,21 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> { ); // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht - // mehr in der Liste sind – nicht automatisch gelöscht, nur zur - // manuellen Durchsicht zurückgegeben. + // mehr in der Liste sind – werden erst nach mindestens 7 Tagen + // 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 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({ type: "fritzbox", diff --git a/apps/frontend/src/components/ScrollToTopButton.tsx b/apps/frontend/src/components/ScrollToTopButton.tsx new file mode 100644 index 0000000..f0133d8 --- /dev/null +++ b/apps/frontend/src/components/ScrollToTopButton.tsx @@ -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> + ); +} diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index 5bf3f7e..d93c475 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { StatusBadge } from "@launchpad/ui"; import { useBackendHealth } from "../../hooks/useBackendHealth.js"; +import { ScrollToTopButton } from "../../components/ScrollToTopButton.js"; const NAV_ITEMS = [ { to: "/", label: "Startseite", icon: "🏠" }, @@ -30,10 +31,13 @@ export function AdminLayout() { return ( <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 - className="flex items-center gap-3 border-b border-black/10 bg-white/80 px-4 py-3 - backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden" + className="fixed inset-x-0 top-0 z-20 flex items-center gap-3 border-b border-black/10 + bg-white/80 px-4 py-3 backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden" > <button onClick={() => setMobileOpen(true)} @@ -114,10 +118,14 @@ export function AdminLayout() { </div> </aside> - {/* sm:ml-56 gleicht die jetzt fixierte Sidebar aus (statt Flexbox-Nebeneinander) */} - <main className="overflow-x-hidden p-4 sm:ml-56 sm:p-8"> + {/* pt-16 auf Mobil gleicht die jetzt fixierte Kopfleiste aus (sonst + 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 /> </main> + + <ScrollToTopButton /> </div> ); } diff --git a/apps/frontend/src/routes/admin/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx index 1811637..0ac8cf4 100644 --- a/apps/frontend/src/routes/admin/BookmarksPage.tsx +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -260,12 +260,8 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void /> </div> <div className="flex items-center gap-2"> - <Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}> - Speichern - </Button> - <Button size="sm" variant="ghost" onClick={onDone}> - Abbrechen - </Button> + <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> + <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button> {mutation.isError ? ( <span className="text-xs text-red-500">{(mutation.error as Error).message}</span> ) : null} @@ -358,12 +354,8 @@ function BookmarkRow({ </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> + <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button> + <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> </div> </td> </tr> diff --git a/apps/frontend/src/routes/admin/CategoriesPage.tsx b/apps/frontend/src/routes/admin/CategoriesPage.tsx index b1c3a24..a3ff9eb 100644 --- a/apps/frontend/src/routes/admin/CategoriesPage.tsx +++ b/apps/frontend/src/routes/admin/CategoriesPage.tsx @@ -125,22 +125,18 @@ function CategoryRow({ 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" /> - <Button size="sm" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending}> - Speichern - </Button> - <Button size="sm" variant="ghost" onClick={() => setEditing(false)}> - Abbrechen + <Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> + <Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"> + ✕ </Button> </> ) : ( <> <span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span> - <Button size="sm" onClick={() => setEditing(true)}> - Umbenennen - </Button> - <Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}> - Löschen + <Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"> + ✏️ </Button> + <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> </> )} </li> diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index ba373cd..1d00413 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -162,12 +162,8 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone /> </div> <div className="flex gap-2"> - <Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}> - Speichern - </Button> - <Button size="sm" variant="ghost" onClick={onDone}> - Abbrechen - </Button> + <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> + <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button> </div> </div> </td> @@ -203,59 +199,36 @@ function NameChangesReview({ const fieldLabel = (field: ScanNameChange["field"]) => 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"> - {list.map((c) => ( + {remaining.map((c) => ( <li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2"> <span className="text-black/70 dark:text-white/70"> {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}" </span> <div className="flex gap-1"> <Button - size="sm" + size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))} - > - Behalten - </Button> + title="Behalten (Vorschlag verwerfen)" + aria-label="Behalten (Vorschlag verwerfen)" + >✕</Button> <Button - size="sm" + size="icon" variant="primary" onClick={() => applyMutation.mutate(c)} - disabled={applyMutation.isPending} - > - Übernehmen - </Button> + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button> </div> </li> ))} </ul> - ); - } - - 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"> + <button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40"> Hinweis schließen </button> </div> @@ -288,12 +261,8 @@ function DeviceNameSuggestionBanner({ Per Reverse-DNS gefunden: „{suggestion}" als Gerätename? </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={onDone}> - Behalten - </Button> - <Button size="sm" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}> - Übernehmen - </Button> + <Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> + <Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button> </div> </div> </div> @@ -327,17 +296,12 @@ function StaleServicesReview({ staleServices, onDone }: { staleServices: Service {s.displayName} ({s.hostname}:{s.port}) </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))}> - Behalten - </Button> + <Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> <Button - size="sm" + size="icon" variant="danger" onClick={() => deleteMutation.mutate(s.id)} - disabled={deleteMutation.isPending} - > - Entfernen - </Button> + disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button> </div> </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"> <div className="flex items-center justify-end gap-2"> - <Button size="sm" onClick={() => setEditing(true)}> - Bearbeiten - </Button> + <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button> <Button size="sm" onClick={() => { @@ -438,13 +400,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { {scanMutation.isPending ? "Scanne …" : "Jetzt scannen"} </Button> <Button - size="sm" + size="icon" variant="danger" onClick={() => deleteMutation.mutate()} - disabled={deleteMutation.isPending} - > - Löschen - </Button> + disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> </div> </td> </tr> diff --git a/apps/frontend/src/routes/admin/ReadLaterPage.tsx b/apps/frontend/src/routes/admin/ReadLaterPage.tsx index 57f5efe..04989c2 100644 --- a/apps/frontend/src/routes/admin/ReadLaterPage.tsx +++ b/apps/frontend/src/routes/admin/ReadLaterPage.tsx @@ -77,17 +77,13 @@ function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void }) /> </div> <div className="flex gap-2"> - <Button size="sm" variant="ghost" onClick={onDone}> - Abbrechen - </Button> + <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button> <Button - size="sm" + size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending || !displayName.trim() || !url.trim()} - > - Speichern - </Button> + >💾</Button> </div> </div> {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-right"> <div className="flex justify-end gap-1"> - <Button size="sm" variant="ghost" onClick={() => setEditing(true)}> - Bearbeiten - </Button> + <Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button> <Button size="sm" variant="secondary" @@ -153,13 +147,13 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) { 🔖 Zu Lesezeichen </Button> <Button - size="sm" + size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} - > - Löschen - </Button> + title="Löschen" + aria-label="Löschen" + >🗑️</Button> </div> </td> </tr> diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 51e7dd8..1131d6e 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -127,57 +127,30 @@ function NameChangesReview({ if (nameChanges.length === 0) return null; 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"> - {list.map((c) => ( + {nameChanges.map((c) => ( <li key={nameChangeKey(c)} className="flex items-center justify-between gap-2"> <span className="text-black/70 dark:text-white/70"> {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „ {c.suggested}" </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={() => onResolve(nameChangeKey(c))}> - Behalten - </Button> + <Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> <Button - size="sm" + size="icon" variant="primary" onClick={() => applyMutation.mutate(c)} - disabled={applyMutation.isPending} - > - Übernehmen - </Button> + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button> </div> </li> ))} </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> ); } @@ -214,17 +187,12 @@ function StaleServicesReview({ {s.displayName} ({s.hostname}:{s.port}) </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={() => onResolve(s.id)}> - Behalten - </Button> + <Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> <Button - size="sm" + size="icon" variant="danger" onClick={() => deleteMutation.mutate(s.id)} - disabled={deleteMutation.isPending} - > - Entfernen - </Button> + disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button> </div> </li> ))} @@ -266,17 +234,12 @@ function DeviceChangesReview({ {c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}" </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)}> - Behalten - </Button> + <Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> <Button - size="sm" + size="icon" variant="primary" onClick={() => applyMutation.mutate(c)} - disabled={applyMutation.isPending} - > - Übernehmen - </Button> + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">✓</Button> </div> </li> ))} @@ -317,17 +280,12 @@ function StaleDevicesReview({ {d.hostname} ({d.ip}) </span> <div className="flex gap-1"> - <Button size="sm" variant="ghost" onClick={() => onResolve(d.id)}> - Behalten - </Button> + <Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)">✕</Button> <Button - size="sm" + size="icon" variant="danger" onClick={() => deleteMutation.mutate(d.id)} - disabled={deleteMutation.isPending} - > - Löschen - </Button> + disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> </div> </li> ))} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index c6f3f10..48c9399 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -229,12 +229,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) </div> <div className="flex w-full items-center 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> + <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> + <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen">✕</Button> {mutation.isError ? ( <span className="text-xs text-red-500">{(mutation.error as Error).message}</span> ) : null} @@ -330,6 +326,11 @@ function ServiceRow({ <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.displayNameEditedManually ? ( + <span title="Name wurde manuell angepasst – Scan-Vorschläge dafür werden nicht mehr angezeigt"> + ✍️ + </span> + ) : null} {!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 @@ -353,7 +354,16 @@ function ServiceRow({ <td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60"> {deviceIp ?? "–"} </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"> {deviceMac ?? "–"} </td> @@ -384,12 +394,8 @@ function ServiceRow({ </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> + <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button> + <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> </div> </td> </tr> diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7fc0b16..5afc69a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -41,6 +41,15 @@ export interface Service { order: number; /** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */ 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 * den bekannten Geräten), damit die Suche auch über den sprechenden diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx index cb4eba8..be6f46b 100644 --- a/packages/ui/src/Button.tsx +++ b/packages/ui/src/Button.tsx @@ -2,7 +2,7 @@ import { forwardRef, type ButtonHTMLAttributes } from "react"; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: "primary" | "secondary" | "danger" | "ghost"; - size?: "sm" | "md"; + size?: "sm" | "md" | "icon"; } 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> = { sm: "px-3 py-1.5 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>(