diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 62d7e6e..92f1f36 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -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 { diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 53e1d49..1681a2d 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -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 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, }) diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 7be54d4..4875218 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -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(), diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 4bb4fbb..33439bb 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -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), + }; }); } diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index 152f46d..37c2bd6 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; export interface AppSettings { recentVisitsLimit: number; + readLaterLimit: number; } async function fetchSettings(): Promise<AppSettings> { diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx index ba55be0..8a48ad6 100644 --- a/apps/frontend/src/router.tsx +++ b/apps/frontend/src/router.tsx @@ -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, diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 328a250..c68b37e 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -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 diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index 0d622c6..5bf3f7e 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -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: "🧩" }, diff --git a/apps/frontend/src/routes/admin/ReadLaterPage.tsx b/apps/frontend/src/routes/admin/ReadLaterPage.tsx new file mode 100644 index 0000000..57f5efe --- /dev/null +++ b/apps/frontend/src/routes/admin/ReadLaterPage.tsx @@ -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> + ); +} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 2eabdc8..c6f3f10 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -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"] }); diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index f088b00..717ed27 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -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">