round16: Geraete-Erkennung per ID statt IP fixen, Schwelle konfigurierbar, Icons auf Font Awesome umgestellt

This commit is contained in:
2026-07-23 13:23:20 +02:00
parent 7035d4ebda
commit 050616a5cf
11 changed files with 101 additions and 54 deletions

View File

@@ -10,6 +10,10 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@launchpad/shared": "workspace:*",
"@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.51.23",

View File

@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
export interface AppSettings {
recentVisitsLimit: number;
readLaterLimit: number;
staleDeviceThresholdDays: number;
}
async function fetchSettings(): Promise<AppSettings> {

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import type { Bookmark } from "@launchpad/shared";
@@ -260,8 +262,8 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
/>
</div>
<div className="flex items-center gap-2">
<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>
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
{mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null}
@@ -354,8 +356,8 @@ function BookmarkRow({
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<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>
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</tr>

View File

@@ -1,4 +1,6 @@
import { useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import type { Category } from "@launchpad/shared";
@@ -125,18 +127,14 @@ 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="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>
<Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</>
) : (
<>
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
<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>
<Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</>
)}
</li>

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
@@ -162,8 +164,8 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone
/>
</div>
<div className="flex gap-2">
<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>
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</div>
</div>
</td>
@@ -218,12 +220,12 @@ function NameChangesReview({
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
title="Behalten (Vorschlag verwerfen)"
aria-label="Behalten (Vorschlag verwerfen)"
></Button>
><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button>
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
@@ -261,8 +263,8 @@ function DeviceNameSuggestionBanner({
Per Reverse-DNS gefunden: {suggestion}" als Gerätename?
</span>
<div className="flex gap-1">
<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>
<Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</div>
</div>
@@ -296,12 +298,12 @@ function StaleServicesReview({ staleServices, onDone }: { staleServices: Service
{s.displayName} ({s.hostname}:{s.port})
</span>
<div className="flex gap-1">
<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="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button>
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
@@ -388,7 +390,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="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button>
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button
size="sm"
onClick={() => {
@@ -403,7 +405,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button>
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</tr>

View File

@@ -1,4 +1,6 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookmark, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js";
@@ -77,13 +79,13 @@ function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void })
/>
</div>
<div className="flex gap-2">
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !displayName.trim() || !url.trim()}
>💾</Button>
><FontAwesomeIcon icon={faXmark} /></Button>
</div>
</div>
{mutation.isError ? (
@@ -136,15 +138,16 @@ 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="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"></Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button
size="sm"
size="icon"
variant="secondary"
onClick={() => promoteMutation.mutate()}
disabled={promoteMutation.isPending}
title="Zu Lesezeichen (Favorit) machen und aus dieser Liste entfernen"
aria-label="Zu Lesezeichen machen"
>
🔖 Zu Lesezeichen
<FontAwesomeIcon icon={faBookmark} />
</Button>
<Button
size="icon"
@@ -153,7 +156,7 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) {
disabled={deleteMutation.isPending}
title="Löschen"
aria-label="Löschen"
>🗑</Button>
><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</tr>

View File

@@ -1,4 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faTrash } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@launchpad/ui";
import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js";
@@ -141,12 +143,12 @@ function NameChangesReview({
{c.suggested}"
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button>
<Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button>
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
@@ -187,12 +189,12 @@ function StaleServicesReview({
{s.displayName} ({s.hostname}:{s.port})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button>
<Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑</Button>
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
@@ -234,12 +236,12 @@ function DeviceChangesReview({
{c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}"
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button>
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button>
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
@@ -280,12 +282,12 @@ function StaleDevicesReview({
{d.hostname} ({d.ip})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button>
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(d.id)}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑</Button>
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
@@ -229,8 +231,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
</div>
<div className="flex w-full items-center gap-2 pt-1">
<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>
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
{mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null}
@@ -394,8 +396,8 @@ function ServiceRow({
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<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>
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</tr>

View File

@@ -20,11 +20,13 @@ function LimitSetting({
hint,
settingKey,
value: currentValue,
max = 50,
}: {
label: string;
hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit";
settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays";
value: number | undefined;
max?: number;
}) {
const queryClient = useQueryClient();
const [value, setValue] = useState<string | null>(null);
@@ -54,7 +56,7 @@ function LimitSetting({
<input
type="number"
min={0}
max={50}
max={max}
value={displayed}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
@@ -323,6 +325,17 @@ export function SettingsPage() {
/>
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Scan</h2>
<LimitSetting
label="Nicht mehr gemeldete Geräte anzeigen nach (Tagen)"
hint="Ein Gerät, das die FritzBox nicht mehr meldet, wird erst nach so vielen Tagen im Scan-Ergebnis zur Durchsicht vorgeschlagen. 0 = sofort beim ersten verpassten Scan."
settingKey="staleDeviceThresholdDays"
value={settings?.staleDeviceThresholdDays}
max={90}
/>
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">HTTPS</h2>
<p className="mb-3 text-sm text-black/50 dark:text-white/50">