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:
2026-07-23 12:32:19 +02:00
parent 70edeeed38
commit 7035d4ebda
12 changed files with 168 additions and 186 deletions

View File

@@ -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 <title> 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,
});
}

View File

@@ -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",

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>(