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

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