round14: Uebernehmen-Bug und Suggestion-Debounce fixen, Read-Later-Verwaltungsseite, konfigurierbares Read-Later-Limit

This commit is contained in:
2026-07-23 09:17:50 +02:00
parent 2772c29036
commit 70edeeed38
11 changed files with 333 additions and 37 deletions

View File

@@ -55,6 +55,8 @@ export function ensureSchema(): void {
visible INTEGER NOT NULL DEFAULT 1,
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
category_edited_manually INTEGER NOT NULL DEFAULT 0,
last_suggested_display_name TEXT,
last_suggested_category TEXT,
port INTEGER NOT NULL,
favicon TEXT,
description TEXT,
@@ -130,6 +132,8 @@ export function ensureSchema(): void {
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "last_suggested_display_name", "TEXT");
ensureColumn("services", "last_suggested_category", "TEXT");
}
function ensureColumn(table: string, column: string, definition: string): void {

View File

@@ -215,6 +215,12 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null {
* wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt
* entweder stillschweigend ignoriert oder automatisch übernommen zu werden.
*
* Ein abweichender Vorschlag wird ERST gemeldet, wenn er zwei Scans in Folge
* derselbe ist (siehe lastSuggestedDisplayName/lastSuggestedCategory in
* schema.ts) - Titel-Erkennung kann pro Scan leicht schwanken (z. B. eine
* SPA, deren <title> nur gelegentlich rechtzeitig geladen ist), ein
* einmaliger Ausrutscher soll nicht sofort als Änderung gemeldet werden.
*
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
*/
@@ -223,21 +229,27 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
const timestamp = nowIso();
if (existing) {
// Rohzeile inkl. der *EditedManually-Flags laden, die mapRow() bewusst
// nicht nach außen gibt (nur intern für den Vergleich hier relevant).
// Rohzeile inkl. der *EditedManually-/lastSuggested*-Felder laden, die
// mapRow() bewusst nicht nach außen gibt (nur intern hier relevant).
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
db.update(services)
.set({
favicon: input.favicon ?? existing.favicon,
description: input.description ?? existing.description,
lastSuggestedDisplayName: input.suggestedDisplayName ?? existingRow.lastSuggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? existingRow.lastSuggestedCategory,
updatedAt: timestamp,
})
.where(eq(services.id, existing.id))
.run();
const nameChanges: ScanNameChange[] = [];
if (input.suggestedDisplayName && input.suggestedDisplayName !== existing.displayName) {
if (
input.suggestedDisplayName &&
input.suggestedDisplayName !== existing.displayName &&
input.suggestedDisplayName === existingRow.lastSuggestedDisplayName
) {
nameChanges.push({
field: "displayName",
current: existing.displayName,
@@ -248,7 +260,8 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
if (
input.suggestedCategory !== undefined &&
(input.suggestedCategory ?? null) !== existing.category &&
input.suggestedCategory
input.suggestedCategory &&
input.suggestedCategory === existingRow.lastSuggestedCategory
) {
nameChanges.push({
field: "category",
@@ -279,6 +292,8 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
port: input.port,
favicon: input.favicon ?? null,
description: input.description ?? null,
lastSuggestedDisplayName: input.suggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? null,
createdAt: timestamp,
updatedAt: timestamp,
})

View File

@@ -53,6 +53,14 @@ export const services = sqliteTable("services", {
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
.notNull()
.default(false),
// Merkt sich den zuletzt vom Scanner berechneten Vorschlag (unabhängig
// davon, ob er zu einer Änderungsmeldung führte). Dient dazu, einmalige
// "Ausrutscher" abzufedern (z. B. eine SPA, deren <title> der Scanner nur
// gelegentlich rechtzeitig sieht): ein neuer, abweichender Vorschlag wird
// erst als Änderung gemeldet, wenn er beim ZWEITEN Mal in Folge wieder
// auftaucht - siehe upsertServiceFromScan.
lastSuggestedDisplayName: text("last_suggested_display_name"),
lastSuggestedCategory: text("last_suggested_category"),
// Bei jedem Scan aktualisierbar
port: integer("port").notNull(),

View File

@@ -6,11 +6,14 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
const all = settingsRepo.listSettings();
return {
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5),
};
});
app.patch("/api/settings", async (request, reply) => {
const body = request.body as { recentVisitsLimit?: number } | undefined;
const body = request.body as
| { recentVisitsLimit?: number; readLaterLimit?: number }
| undefined;
if (body?.recentVisitsLimit !== undefined) {
const value = Number(body.recentVisitsLimit);
@@ -20,7 +23,18 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
}
if (body?.readLaterLimit !== undefined) {
const value = Number(body.readLaterLimit);
if (!Number.isFinite(value) || value < 0 || value > 50) {
return reply.code(400).send({ error: "readLaterLimit muss zwischen 0 und 50 liegen" });
}
settingsRepo.setSetting("readLaterLimit", String(Math.round(value)));
}
const all = settingsRepo.listSettings();
return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5) };
return {
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5),
};
});
}

View File

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

View File

@@ -7,6 +7,7 @@ import { ServicesPage } from "./routes/admin/ServicesPage.js";
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
import { ScannerPage } from "./routes/admin/ScannerPage.js";
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.js";
import { PluginsPage } from "./routes/admin/PluginsPage.js";
import { SettingsPage } from "./routes/admin/SettingsPage.js";
import { LogsPage } from "./routes/admin/LogsPage.js";
@@ -72,6 +73,12 @@ const adminScannerRoute = createRoute({
component: ScannerPage,
});
const adminReadLaterRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/read-later",
component: ReadLaterPage,
});
const adminPluginsRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/plugins",
@@ -100,6 +107,7 @@ const routeTree = rootRoute.addChildren([
adminBookmarksRoute,
adminCategoriesRoute,
adminScannerRoute,
adminReadLaterRoute,
adminPluginsRoute,
adminSettingsRoute,
adminLogsRoute,

View File

@@ -6,6 +6,7 @@ import { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
import { useDevices } from "../hooks/useDevices.js";
import { useSettings } from "../hooks/useSettings.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.js";
import { useCategories } from "../hooks/useCategories.js";
@@ -94,6 +95,7 @@ export function HomePage() {
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
const { data: devices } = useDevices();
const { data: settings } = useSettings();
const { data: categories } = useCategories();
const { data: recentVisits } = useRecentVisits();
const { data: readLaterItems } = useReadLater();
@@ -317,23 +319,27 @@ export function HomePage() {
<div className="mt-3 flex flex-col gap-3">
<ReadLaterBox />
{readLaterItems && readLaterItems.length > 0 ? (
<div className="flex flex-wrap items-center justify-center gap-2">
{readLaterItems.slice(0, 6).map((item) => (
<button
key={item.id}
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
title={item.displayName}
className="flex items-center gap-1.5 rounded-full border border-black/10
bg-white/50 px-2.5 py-1 text-xs text-black/60 hover:bg-black/5
dark:border-white/10 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<span className="max-w-[8rem] truncate">{item.displayName}</span>
</button>
))}
</div>
) : null}
{(() => {
const readLaterLimit = settings?.readLaterLimit ?? 5;
if (!readLaterItems || readLaterItems.length === 0 || readLaterLimit <= 0) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{readLaterItems.slice(0, readLaterLimit).map((item) => (
<button
key={item.id}
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
title={item.displayName}
className="flex items-center gap-1.5 rounded-full border border-black/10
bg-white/50 px-2.5 py-1 text-xs text-black/60 hover:bg-black/5
dark:border-white/10 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<span className="max-w-[8rem] truncate">{item.displayName}</span>
</button>
))}
</div>
);
})()}
{recentVisits && recentVisits.length > 0 ? (
<FavoritesBar

View File

@@ -9,6 +9,7 @@ const NAV_ITEMS = [
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
{ to: "/admin/services", label: "Dienste", icon: "🔗" },
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: "🔖" },
{ to: "/admin/read-later", label: "Später lesen", icon: "📌" },
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" },
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" },

View File

@@ -0,0 +1,211 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
async function patchReadLaterItem(id: string, patch: { url?: string; displayName?: string }) {
const res = await fetch(`/api/read-later/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Eintrag konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
async function deleteReadLaterItem(id: string) {
const res = await fetch(`/api/read-later/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Eintrag konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
async function promoteReadLaterItem(id: string) {
const res = await fetch(`/api/read-later/${id}/promote`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Konnte nicht zu Lesezeichen gemacht werden (HTTP ${res.status})`);
}
return res.json();
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void }) {
const queryClient = useQueryClient();
const [displayName, setDisplayName] = useState(item.displayName);
const [url, setUrl] = useState(item.url);
const mutation = useMutation({
mutationFn: () => patchReadLaterItem(item.id, { displayName: displayName.trim(), url: url.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["read-later"] });
onDone();
},
});
return (
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
<td colSpan={4} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-80 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"
/>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={onDone}>
Abbrechen
</Button>
<Button
size="sm"
variant="primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !displayName.trim() || !url.trim()}
>
Speichern
</Button>
</div>
</div>
{mutation.isError ? (
<p className="mt-2 text-xs text-red-500">{(mutation.error as Error).message}</p>
) : null}
</td>
</tr>
);
}
function ReadLaterRow({ item }: { item: ReadLaterItem }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const promoteMutation = useMutation({
mutationFn: () => promoteReadLaterItem(item.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["read-later"] });
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteReadLaterItem(item.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["read-later"] }),
});
if (editing) {
return <EditRow item={item} onDone={() => setEditing(false)} />;
}
return (
<tr className="border-b border-black/5 last:border-0 hover:bg-black/[0.02] dark:border-white/5 dark:hover:bg-white/5">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-black hover:underline dark:text-white"
>
{item.displayName}
</a>
</div>
</td>
<td className="max-w-xs truncate px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{item.url}
</td>
<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="sm"
variant="secondary"
onClick={() => promoteMutation.mutate()}
disabled={promoteMutation.isPending}
title="Zu Lesezeichen (Favorit) machen und aus dieser Liste entfernen"
>
🔖 Zu Lesezeichen
</Button>
<Button
size="sm"
variant="danger"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
Löschen
</Button>
</div>
</td>
</tr>
);
}
export function ReadLaterPage() {
const { data: items, isLoading, isError } = useReadLater();
return (
<div>
<AdminPageHeader
title="Später lesen"
description='Von der Startseite gespeicherte Links. "Zu Lesezeichen" übernimmt einen Eintrag dauerhaft als Favorit und entfernt ihn hier. Wie viele Einträge auf der Startseite angezeigt werden, lässt sich unter Einstellungen konfigurieren.'
/>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
) : isError ? (
<p className="text-sm text-red-500">Liste konnte nicht geladen werden.</p>
) : items && items.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2 font-medium">Gespeichert</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{items.map((item) => (
<ReadLaterRow key={item.id} item={item} />
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
Noch nichts gespeichert. Über die Startseite lassen sich Links per Merken" ablegen.
</p>
)}
</div>
);
}

View File

@@ -119,10 +119,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
const mutation = useMutation({
mutationFn: () =>
patchService(service.id, {
displayName: displayName.trim(),
category: category.trim() || null,
mutationFn: () => {
const patch: Record<string, unknown> = {
alias: alias
.split(",")
.map((a) => a.trim())
@@ -132,7 +130,18 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
port: portNumber,
https,
url: previewUrl,
}),
};
// displayName/category nur mitschicken, wenn sich der Wert tatsächlich
// geändert hat - sonst würde jedes Speichern (z. B. nur um den Port zu
// korrigieren) die *EditedManually-Flag fälschlich setzen, obwohl der
// Nutzer diese beiden Felder gar nicht angefasst hat (siehe
// upsertServiceFromScan/updateService-Doku in services.ts).
const trimmedName = displayName.trim();
if (trimmedName !== service.displayName) patch.displayName = trimmedName;
const trimmedCategory = category.trim() || null;
if (trimmedCategory !== service.category) patch.category = trimmedCategory;
return patchService(service.id, patch);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });

View File

@@ -15,8 +15,17 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
function RecentVisitsLimitSetting() {
const { data: settings } = useSettings();
function LimitSetting({
label,
hint,
settingKey,
value: currentValue,
}: {
label: string;
hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit";
value: number | undefined;
}) {
const queryClient = useQueryClient();
const [value, setValue] = useState<string | null>(null);
@@ -25,7 +34,7 @@ function RecentVisitsLimitSetting() {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recentVisitsLimit: limit }),
body: JSON.stringify({ [settingKey]: limit }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
@@ -33,15 +42,13 @@ function RecentVisitsLimitSetting() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const displayed = value ?? String(settings?.recentVisitsLimit ?? 5);
const displayed = value ?? String(currentValue ?? 5);
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">
Anzahl Zuletzt besucht"
</span>
<p className="text-xs text-black/30 dark:text-white/30">0 = Leiste ausblenden</p>
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
</div>
<div className="flex items-center gap-2">
<input
@@ -282,6 +289,7 @@ function DangerZone() {
export function SettingsPage() {
const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme();
const { data: settings } = useSettings();
return (
<div>
@@ -301,7 +309,18 @@ export function SettingsPage() {
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} wechseln
</button>
</div>
<RecentVisitsLimitSetting />
<LimitSetting
label='Anzahl „Zuletzt besucht"'
hint="0 = Leiste ausblenden"
settingKey="recentVisitsLimit"
value={settings?.recentVisitsLimit}
/>
<LimitSetting
label='Anzahl „Später lesen" auf der Startseite'
hint="0 = Box ausblenden. Ändert nichts an der Gesamtliste (siehe Admin -> Später lesen)."
settingKey="readLaterLimit"
value={settings?.readLaterLimit}
/>
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">