generated from Dicken/dickendock
round14: Uebernehmen-Bug und Suggestion-Debounce fixen, Read-Later-Verwaltungsseite, konfigurierbares Read-Later-Limit
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface AppSettings {
|
||||
recentVisitsLimit: number;
|
||||
readLaterLimit: number;
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<AppSettings> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "🧩" },
|
||||
|
||||
211
apps/frontend/src/routes/admin/ReadLaterPage.tsx
Normal file
211
apps/frontend/src/routes/admin/ReadLaterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user