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[],
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user