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,
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
|
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
|
||||||
category_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,
|
port INTEGER NOT NULL,
|
||||||
favicon TEXT,
|
favicon TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -130,6 +132,8 @@ export function ensureSchema(): void {
|
|||||||
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
|
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
|
||||||
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
||||||
ensureColumn("services", "category_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 {
|
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
|
* wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt
|
||||||
* entweder stillschweigend ignoriert oder automatisch übernommen zu werden.
|
* 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
|
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
||||||
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
||||||
*/
|
*/
|
||||||
@@ -223,21 +229,27 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Rohzeile inkl. der *EditedManually-Flags laden, die mapRow() bewusst
|
// Rohzeile inkl. der *EditedManually-/lastSuggested*-Felder laden, die
|
||||||
// nicht nach außen gibt (nur intern für den Vergleich hier relevant).
|
// mapRow() bewusst nicht nach außen gibt (nur intern hier relevant).
|
||||||
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
|
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
|
||||||
|
|
||||||
db.update(services)
|
db.update(services)
|
||||||
.set({
|
.set({
|
||||||
favicon: input.favicon ?? existing.favicon,
|
favicon: input.favicon ?? existing.favicon,
|
||||||
description: input.description ?? existing.description,
|
description: input.description ?? existing.description,
|
||||||
|
lastSuggestedDisplayName: input.suggestedDisplayName ?? existingRow.lastSuggestedDisplayName,
|
||||||
|
lastSuggestedCategory: input.suggestedCategory ?? existingRow.lastSuggestedCategory,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
})
|
})
|
||||||
.where(eq(services.id, existing.id))
|
.where(eq(services.id, existing.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const nameChanges: ScanNameChange[] = [];
|
const nameChanges: ScanNameChange[] = [];
|
||||||
if (input.suggestedDisplayName && input.suggestedDisplayName !== existing.displayName) {
|
if (
|
||||||
|
input.suggestedDisplayName &&
|
||||||
|
input.suggestedDisplayName !== existing.displayName &&
|
||||||
|
input.suggestedDisplayName === existingRow.lastSuggestedDisplayName
|
||||||
|
) {
|
||||||
nameChanges.push({
|
nameChanges.push({
|
||||||
field: "displayName",
|
field: "displayName",
|
||||||
current: existing.displayName,
|
current: existing.displayName,
|
||||||
@@ -248,7 +260,8 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
if (
|
if (
|
||||||
input.suggestedCategory !== undefined &&
|
input.suggestedCategory !== undefined &&
|
||||||
(input.suggestedCategory ?? null) !== existing.category &&
|
(input.suggestedCategory ?? null) !== existing.category &&
|
||||||
input.suggestedCategory
|
input.suggestedCategory &&
|
||||||
|
input.suggestedCategory === existingRow.lastSuggestedCategory
|
||||||
) {
|
) {
|
||||||
nameChanges.push({
|
nameChanges.push({
|
||||||
field: "category",
|
field: "category",
|
||||||
@@ -279,6 +292,8 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
port: input.port,
|
port: input.port,
|
||||||
favicon: input.favicon ?? null,
|
favicon: input.favicon ?? null,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
|
lastSuggestedDisplayName: input.suggestedDisplayName,
|
||||||
|
lastSuggestedCategory: input.suggestedCategory ?? null,
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export const services = sqliteTable("services", {
|
|||||||
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
|
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.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
|
// Bei jedem Scan aktualisierbar
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const all = settingsRepo.listSettings();
|
const all = settingsRepo.listSettings();
|
||||||
return {
|
return {
|
||||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||||
|
readLaterLimit: Number(all.readLaterLimit ?? 5),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/settings", async (request, reply) => {
|
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) {
|
if (body?.recentVisitsLimit !== undefined) {
|
||||||
const value = Number(body.recentVisitsLimit);
|
const value = Number(body.recentVisitsLimit);
|
||||||
@@ -20,7 +23,18 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
|
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();
|
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 {
|
export interface AppSettings {
|
||||||
recentVisitsLimit: number;
|
recentVisitsLimit: number;
|
||||||
|
readLaterLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSettings(): Promise<AppSettings> {
|
async function fetchSettings(): Promise<AppSettings> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ServicesPage } from "./routes/admin/ServicesPage.js";
|
|||||||
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
|
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
|
||||||
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
||||||
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
||||||
|
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.js";
|
||||||
import { PluginsPage } from "./routes/admin/PluginsPage.js";
|
import { PluginsPage } from "./routes/admin/PluginsPage.js";
|
||||||
import { SettingsPage } from "./routes/admin/SettingsPage.js";
|
import { SettingsPage } from "./routes/admin/SettingsPage.js";
|
||||||
import { LogsPage } from "./routes/admin/LogsPage.js";
|
import { LogsPage } from "./routes/admin/LogsPage.js";
|
||||||
@@ -72,6 +73,12 @@ const adminScannerRoute = createRoute({
|
|||||||
component: ScannerPage,
|
component: ScannerPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminReadLaterRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/read-later",
|
||||||
|
component: ReadLaterPage,
|
||||||
|
});
|
||||||
|
|
||||||
const adminPluginsRoute = createRoute({
|
const adminPluginsRoute = createRoute({
|
||||||
getParentRoute: () => adminRoute,
|
getParentRoute: () => adminRoute,
|
||||||
path: "/plugins",
|
path: "/plugins",
|
||||||
@@ -100,6 +107,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
adminBookmarksRoute,
|
adminBookmarksRoute,
|
||||||
adminCategoriesRoute,
|
adminCategoriesRoute,
|
||||||
adminScannerRoute,
|
adminScannerRoute,
|
||||||
|
adminReadLaterRoute,
|
||||||
adminPluginsRoute,
|
adminPluginsRoute,
|
||||||
adminSettingsRoute,
|
adminSettingsRoute,
|
||||||
adminLogsRoute,
|
adminLogsRoute,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { rankServices, type SearchResult } from "@launchpad/shared";
|
|||||||
import { useServices } from "../hooks/useServices.js";
|
import { useServices } from "../hooks/useServices.js";
|
||||||
import { useBookmarks } from "../hooks/useBookmarks.js";
|
import { useBookmarks } from "../hooks/useBookmarks.js";
|
||||||
import { useDevices } from "../hooks/useDevices.js";
|
import { useDevices } from "../hooks/useDevices.js";
|
||||||
|
import { useSettings } from "../hooks/useSettings.js";
|
||||||
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||||
import { useTheme } from "../hooks/useTheme.js";
|
import { useTheme } from "../hooks/useTheme.js";
|
||||||
import { useCategories } from "../hooks/useCategories.js";
|
import { useCategories } from "../hooks/useCategories.js";
|
||||||
@@ -94,6 +95,7 @@ export function HomePage() {
|
|||||||
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
|
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
|
||||||
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
||||||
const { data: devices } = useDevices();
|
const { data: devices } = useDevices();
|
||||||
|
const { data: settings } = useSettings();
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
const { data: recentVisits } = useRecentVisits();
|
const { data: recentVisits } = useRecentVisits();
|
||||||
const { data: readLaterItems } = useReadLater();
|
const { data: readLaterItems } = useReadLater();
|
||||||
@@ -317,23 +319,27 @@ export function HomePage() {
|
|||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
<ReadLaterBox />
|
<ReadLaterBox />
|
||||||
|
|
||||||
{readLaterItems && readLaterItems.length > 0 ? (
|
{(() => {
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
const readLaterLimit = settings?.readLaterLimit ?? 5;
|
||||||
{readLaterItems.slice(0, 6).map((item) => (
|
if (!readLaterItems || readLaterItems.length === 0 || readLaterLimit <= 0) return null;
|
||||||
<button
|
return (
|
||||||
key={item.id}
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
|
{readLaterItems.slice(0, readLaterLimit).map((item) => (
|
||||||
title={item.displayName}
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-full border border-black/10
|
key={item.id}
|
||||||
bg-white/50 px-2.5 py-1 text-xs text-black/60 hover:bg-black/5
|
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
|
||||||
dark:border-white/10 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
|
title={item.displayName}
|
||||||
>
|
className="flex items-center gap-1.5 rounded-full border border-black/10
|
||||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
|
bg-white/50 px-2.5 py-1 text-xs text-black/60 hover:bg-black/5
|
||||||
<span className="max-w-[8rem] truncate">{item.displayName}</span>
|
dark:border-white/10 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
|
||||||
</button>
|
>
|
||||||
))}
|
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
|
||||||
</div>
|
<span className="max-w-[8rem] truncate">{item.displayName}</span>
|
||||||
) : null}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{recentVisits && recentVisits.length > 0 ? (
|
{recentVisits && recentVisits.length > 0 ? (
|
||||||
<FavoritesBar
|
<FavoritesBar
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
|
|||||||
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
|
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
|
||||||
{ to: "/admin/services", label: "Dienste", icon: "🔗" },
|
{ to: "/admin/services", label: "Dienste", icon: "🔗" },
|
||||||
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: "🔖" },
|
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: "🔖" },
|
||||||
|
{ to: "/admin/read-later", label: "Später lesen", icon: "📌" },
|
||||||
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" },
|
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" },
|
||||||
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
|
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
|
||||||
{ to: "/admin/plugins", label: "Plugins", 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 previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
patchService(service.id, {
|
const patch: Record<string, unknown> = {
|
||||||
displayName: displayName.trim(),
|
|
||||||
category: category.trim() || null,
|
|
||||||
alias: alias
|
alias: alias
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
@@ -132,7 +130,18 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
|||||||
port: portNumber,
|
port: portNumber,
|
||||||
https,
|
https,
|
||||||
url: previewUrl,
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecentVisitsLimitSetting() {
|
function LimitSetting({
|
||||||
const { data: settings } = useSettings();
|
label,
|
||||||
|
hint,
|
||||||
|
settingKey,
|
||||||
|
value: currentValue,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
settingKey: "recentVisitsLimit" | "readLaterLimit";
|
||||||
|
value: number | undefined;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [value, setValue] = useState<string | null>(null);
|
const [value, setValue] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -25,7 +34,7 @@ function RecentVisitsLimitSetting() {
|
|||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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})`);
|
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -33,15 +42,13 @@ function RecentVisitsLimitSetting() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayed = value ?? String(settings?.recentVisitsLimit ?? 5);
|
const displayed = value ?? String(currentValue ?? 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-black/50 dark:text-white/50">
|
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
|
||||||
Anzahl „Zuletzt besucht"
|
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
|
||||||
</span>
|
|
||||||
<p className="text-xs text-black/30 dark:text-white/30">0 = Leiste ausblenden</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -282,6 +289,7 @@ function DangerZone() {
|
|||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { health, error } = useBackendHealth();
|
const { health, error } = useBackendHealth();
|
||||||
const [theme, toggleTheme] = useTheme();
|
const [theme, toggleTheme] = useTheme();
|
||||||
|
const { data: settings } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -301,7 +309,18 @@ export function SettingsPage() {
|
|||||||
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} – wechseln
|
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} – wechseln
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
|||||||
Reference in New Issue
Block a user