From 96cf29fef4b06450b0b9b266fc14eeb6d7df21b9 Mon Sep 17 00:00:00 2001 From: Dicken Date: Mon, 20 Jul 2026 07:52:38 +0200 Subject: [PATCH] Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung --- README.md | 39 +- apps/backend/src/db/client.ts | 27 +- apps/backend/src/db/repositories/bookmarks.ts | 1 + .../backend/src/db/repositories/categories.ts | 4 +- apps/backend/src/db/repositories/readLater.ts | 77 ++++ .../src/db/repositories/recentVisits.ts | 78 ++++ apps/backend/src/db/repositories/settings.ts | 22 + apps/backend/src/db/schema.ts | 32 ++ apps/backend/src/index.ts | 8 + apps/backend/src/routes/bookmarks.ts | 15 +- apps/backend/src/routes/readLater.ts | 71 +++ apps/backend/src/routes/recentVisits.ts | 27 ++ apps/backend/src/routes/scan.ts | 25 +- apps/backend/src/routes/services.ts | 171 -------- apps/backend/src/routes/settings.ts | 26 ++ apps/backend/src/routes/transfer.ts | 250 +++++++++++ apps/backend/src/scanner/http.ts | 11 + apps/frontend/docker-entrypoint.sh | 37 +- apps/frontend/src/hooks/useReadLater.ts | 24 ++ apps/frontend/src/hooks/useRecentVisits.ts | 29 ++ apps/frontend/src/hooks/useSettings.ts | 20 + apps/frontend/src/index.css | 14 + apps/frontend/src/main.tsx | 17 + apps/frontend/src/routes/HomePage.tsx | 268 +++++++++--- .../src/routes/admin/BookmarksPage.tsx | 97 ++++- .../src/routes/admin/DashboardPage.tsx | 20 +- .../frontend/src/routes/admin/DevicesPage.tsx | 404 +++++++++++++++--- .../frontend/src/routes/admin/ScannerPage.tsx | 72 +++- .../src/routes/admin/ServicesPage.tsx | 142 ++---- .../src/routes/admin/SettingsPage.tsx | 223 +++++++++- docs/ROADMAP.md | 67 +++ packages/shared/src/index.ts | 8 +- packages/shared/src/schemas.ts | 5 + packages/ui/src/Favicon.tsx | 21 +- packages/ui/src/FavoritesBar.tsx | 71 +-- packages/ui/src/ResultsList.tsx | 56 ++- packages/ui/tsconfig.json | 3 +- 37 files changed, 1988 insertions(+), 494 deletions(-) create mode 100644 apps/backend/src/db/repositories/readLater.ts create mode 100644 apps/backend/src/db/repositories/recentVisits.ts create mode 100644 apps/backend/src/db/repositories/settings.ts create mode 100644 apps/backend/src/routes/readLater.ts create mode 100644 apps/backend/src/routes/recentVisits.ts create mode 100644 apps/backend/src/routes/settings.ts create mode 100644 apps/backend/src/routes/transfer.ts create mode 100644 apps/frontend/src/hooks/useReadLater.ts create mode 100644 apps/frontend/src/hooks/useRecentVisits.ts create mode 100644 apps/frontend/src/hooks/useSettings.ts diff --git a/README.md b/README.md index 84928d5..3b16574 100644 --- a/README.md +++ b/README.md @@ -199,32 +199,49 @@ POST /api/services erfordert existierende deviceId PATCH /api/services/reorder Body: [{ id, order }, ...] PATCH /api/services/:id DELETE /api/services/:id -GET /api/services/export ?format=csv|xlsx|json (Default csv) -POST /api/services/import Body: { format, content: base64 }. Legt nur - neue Dienste an (Abgleich über Gerät+Port), - überschreibt/dupliziert nie Bestehendes. GET /api/bookmarks -POST /api/bookmarks Titel/Favicon werden automatisch geladen, - falls kein displayName angegeben ist +POST /api/bookmarks Titel/Favicon/Beschreibung werden automatisch + geladen, falls nicht angegeben PATCH /api/bookmarks/reorder Body: [{ id, order }, ...] PATCH /api/bookmarks/:id DELETE /api/bookmarks/:id GET /api/categories -POST /api/categories +POST /api/categories optional color (Hex, z. B. "#3b82f6") PATCH /api/categories/reorder Body: [{ id, order }, ...] PATCH /api/categories/:id Umbenennen (aktualisiert automatisch alle Dienste mit altem Namen) DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), werden aber nicht gelöscht -POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Titel, - Favicon, Softwareerkennung); legt/aktualisiert Dienste -POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064 - (erfordert FRITZBOX_HOST/USERNAME/PASSWORD) +POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält + zusätzlich staleServices (nicht mehr gefundene + Dienste, werden NICHT automatisch gelöscht) +POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064; + Antwort enthält zusätzlich staleDevices GET /api/logs optional ?limit= (Default 100, Max 500) +GET /api/transfer/export ?format=csv|xlsx|json (Default csv). Geräte + UND Dienste in einer Datei, Spalte "type" + unterscheidet die Zeilen. +POST /api/transfer/import Body: { format, content: base64 }. Legt nur + neue Geräte/Dienste an, überschreibt/ + dupliziert nie Bestehendes. + +GET /api/recent-visits optional ?limit= +POST /api/recent-visits Body: { itemType: "service"|"bookmark", itemId } +DELETE /api/recent-visits + +GET /api/settings +PATCH /api/settings Body: { recentVisitsLimit } + +GET /api/read-later +POST /api/read-later Titel/Favicon werden automatisch geladen +PATCH /api/read-later/:id +DELETE /api/read-later/:id +POST /api/read-later/:id/promote verschiebt zu Lesezeichen (als Favorit) + POST /api/reset Löscht ALLE Geräte + Dienste (Cascade). Erfordert Body { "confirm": true }, sonst 400. diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 4a17f55..cec6f02 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -64,6 +64,7 @@ export function ensureSchema(): void { id TEXT PRIMARY KEY, name TEXT NOT NULL, "order" REAL NOT NULL DEFAULT 0, + color TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -92,14 +93,36 @@ export function ensureSchema(): void { created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS recent_visits ( + id TEXT PRIMARY KEY, + item_type TEXT NOT NULL, + item_id TEXT NOT NULL, + visited_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS read_later ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + display_name TEXT NOT NULL, + favicon TEXT, + saved_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); `); - // Leichte Migration für Datenbanken, die vor Einführung von "visible" - // angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits + // Leichte Migration für Datenbanken, die vor Einführung von "visible"/ + // "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits // existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier // manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit // sich am bisherigen Verhalten nichts unerwartet ändert. ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1"); + ensureColumn("categories", "color", "TEXT"); } function ensureColumn(table: string, column: string, definition: string): void { diff --git a/apps/backend/src/db/repositories/bookmarks.ts b/apps/backend/src/db/repositories/bookmarks.ts index 0bd4ad4..df31b9d 100644 --- a/apps/backend/src/db/repositories/bookmarks.ts +++ b/apps/backend/src/db/repositories/bookmarks.ts @@ -85,6 +85,7 @@ export function updateBookmark(id: string, input: BookmarkUpdateInput): Bookmark ...(input.description !== undefined && { description: input.description }), ...(input.category !== undefined && { category: input.category }), ...(input.icon !== undefined && { icon: input.icon }), + ...(input.favicon !== undefined && { favicon: input.favicon }), ...(input.favorite !== undefined && { favorite: input.favorite }), ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), ...(input.order !== undefined && { order: input.order }), diff --git a/apps/backend/src/db/repositories/categories.ts b/apps/backend/src/db/repositories/categories.ts index a7c31d6..e02b296 100644 --- a/apps/backend/src/db/repositories/categories.ts +++ b/apps/backend/src/db/repositories/categories.ts @@ -14,7 +14,7 @@ function nowIso(): string { } function mapRow(row: typeof categories.$inferSelect): Category { - return { id: row.id, name: row.name, order: row.order }; + return { id: row.id, name: row.name, order: row.order, color: row.color }; } export function listCategories(): Category[] { @@ -42,6 +42,7 @@ export function createCategory(input: CategoryCreateInput): Category { id, name: input.name, order: nextOrder, + color: input.color ?? null, createdAt: timestamp, updatedAt: timestamp, }) @@ -71,6 +72,7 @@ export function updateCategory(id: string, input: CategoryUpdateInput): Category db.update(categories) .set({ ...(input.name !== undefined && { name: input.name }), + ...(input.color !== undefined && { color: input.color }), updatedAt: timestamp, }) .where(eq(categories.id, id)) diff --git a/apps/backend/src/db/repositories/readLater.ts b/apps/backend/src/db/repositories/readLater.ts new file mode 100644 index 0000000..1dabe47 --- /dev/null +++ b/apps/backend/src/db/repositories/readLater.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { desc, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { readLater } from "../schema.js"; + +export interface ReadLaterItem { + id: string; + url: string; + displayName: string; + favicon: string | null; + savedAt: string; +} + +function mapRow(row: typeof readLater.$inferSelect): ReadLaterItem { + return { + id: row.id, + url: row.url, + displayName: row.displayName, + favicon: row.favicon, + savedAt: row.savedAt, + }; +} + +export function listReadLater(): ReadLaterItem[] { + return db.select().from(readLater).orderBy(desc(readLater.savedAt)).all().map(mapRow); +} + +export function getReadLaterItem(id: string): ReadLaterItem | null { + const row = db.select().from(readLater).where(eq(readLater.id, id)).get(); + return row ? mapRow(row) : null; +} + +export function createReadLaterItem(input: { + url: string; + displayName: string; + favicon?: string | null; +}): ReadLaterItem { + const id = randomUUID(); + const timestamp = new Date().toISOString(); + + db.insert(readLater) + .values({ + id, + url: input.url, + displayName: input.displayName, + favicon: input.favicon ?? null, + savedAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return getReadLaterItem(id)!; +} + +export function updateReadLaterItem( + id: string, + input: { url?: string; displayName?: string } +): ReadLaterItem | null { + const existing = getReadLaterItem(id); + if (!existing) return null; + + db.update(readLater) + .set({ + ...(input.url !== undefined && { url: input.url }), + ...(input.displayName !== undefined && { displayName: input.displayName }), + updatedAt: new Date().toISOString(), + }) + .where(eq(readLater.id, id)) + .run(); + + return getReadLaterItem(id); +} + +export function deleteReadLaterItem(id: string): boolean { + const result = db.delete(readLater).where(eq(readLater.id, id)).run(); + return result.changes > 0; +} diff --git a/apps/backend/src/db/repositories/recentVisits.ts b/apps/backend/src/db/repositories/recentVisits.ts new file mode 100644 index 0000000..4300786 --- /dev/null +++ b/apps/backend/src/db/repositories/recentVisits.ts @@ -0,0 +1,78 @@ +import { randomUUID } from "node:crypto"; +import { desc, eq } from "drizzle-orm"; +import type { Bookmark, Service } from "@launchpad/shared"; +import { db } from "../client.js"; +import { recentVisits } from "../schema.js"; +import * as serviceRepo from "./services.js"; +import * as bookmarkRepo from "./bookmarks.js"; +import { getSetting } from "./settings.js"; + +export type VisitedItem = (Service & { kind: "service" }) | (Bookmark & { kind: "bookmark" }); + +export function recordVisit(itemType: "service" | "bookmark", itemId: string): void { + db.insert(recentVisits) + .values({ + id: randomUUID(), + itemType, + itemId, + visitedAt: new Date().toISOString(), + }) + .run(); +} + +const DEFAULT_LIMIT = 5; + +export function getRecentVisitsLimit(): number { + const stored = getSetting("recentVisitsLimit"); + const parsed = stored ? Number(stored) : DEFAULT_LIMIT; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT; +} + +/** + * Liefert die zuletzt besuchten Dienste/Lesezeichen, jedes Element nur + * einmal (neuester Besuch zählt), aufgelöst zu den echten Objekten. Bereits + * gelöschte Ziele werden übersprungen. Limit kommt aus den Einstellungen + * (Admin -> Einstellungen), Default 5. + */ +export function listRecentVisits(limitOverride?: number): VisitedItem[] { + const limit = limitOverride ?? getRecentVisitsLimit(); + + // Etwas mehr als das Limit an Rohzeilen lesen, falls Duplikate/gelöschte + // Ziele darunter sind – reicht in der Praxis (Homelab-Größenordnung) locker. + const rows = db + .select() + .from(recentVisits) + .orderBy(desc(recentVisits.visitedAt)) + .limit(limit * 5 + 20) + .all(); + + const seen = new Set(); + const result: VisitedItem[] = []; + + for (const row of rows) { + const key = `${row.itemType}:${row.itemId}`; + if (seen.has(key)) continue; + seen.add(key); + + if (row.itemType === "service") { + const service = serviceRepo.getService(row.itemId); + if (service) result.push({ ...service, kind: "service" }); + } else if (row.itemType === "bookmark") { + const bookmark = bookmarkRepo.getBookmark(row.itemId); + if (bookmark) result.push({ ...bookmark, kind: "bookmark" }); + } + + if (result.length >= limit) break; + } + + return result; +} + +export function clearRecentVisits(): void { + db.delete(recentVisits).run(); +} + +/** Räumt Verweise auf eine gelöschte Ressource auf (verhindert totes Wachstum). */ +export function pruneVisitsFor(itemId: string): void { + db.delete(recentVisits).where(eq(recentVisits.itemId, itemId)).run(); +} diff --git a/apps/backend/src/db/repositories/settings.ts b/apps/backend/src/db/repositories/settings.ts new file mode 100644 index 0000000..01c1f26 --- /dev/null +++ b/apps/backend/src/db/repositories/settings.ts @@ -0,0 +1,22 @@ +import { eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { appSettings } from "../schema.js"; + +export function getSetting(key: string): string | null { + const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get(); + return row?.value ?? null; +} + +export function setSetting(key: string, value: string): void { + const existing = getSetting(key); + if (existing === null) { + db.insert(appSettings).values({ key, value }).run(); + } else { + db.update(appSettings).set({ value }).where(eq(appSettings.key, key)).run(); + } +} + +export function listSettings(): Record { + const rows = db.select().from(appSettings).all(); + return Object.fromEntries(rows.map((r) => [r.key, r.value])); +} diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 85f2d11..5ffa317 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -59,6 +59,7 @@ export const categories = sqliteTable("categories", { id: text("id").primaryKey(), name: text("name").notNull(), order: real("order").notNull().default(0), + color: text("color"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }); @@ -97,3 +98,34 @@ export const bookmarks = sqliteTable("bookmarks", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }); + +/** + * Zuletzt aus der Suche geöffnete Dienste/Lesezeichen, für die + * "Zuletzt besucht"-Leiste auf der Startseite. + */ +export const recentVisits = sqliteTable("recent_visits", { + id: text("id").primaryKey(), + itemType: text("item_type").notNull(), // "service" | "bookmark" + itemId: text("item_id").notNull(), + visitedAt: text("visited_at").notNull(), +}); + +/** Freie Schlüssel-Wert-Einstellungen, z. B. Anzahl "Zuletzt besucht". */ +export const appSettings = sqliteTable("app_settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), +}); + +/** + * "Später lesen": schnell von der Startseite abgelegte Links, unabhängig + * von Lesezeichen/Diensten. Können später zu einem Lesezeichen befördert + * oder gelöscht werden. + */ +export const readLater = sqliteTable("read_later", { + id: text("id").primaryKey(), + url: text("url").notNull(), + displayName: text("display_name").notNull(), + favicon: text("favicon"), + savedAt: text("saved_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 47e05c6..a3cf51e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -10,7 +10,11 @@ import { scanRoutes } from "./routes/scan.js"; import { logRoutes } from "./routes/logs.js"; import { pluginRoutes } from "./routes/plugins.js"; import { resetRoutes } from "./routes/reset.js"; +import { transferRoutes } from "./routes/transfer.js"; import { bookmarkRoutes } from "./routes/bookmarks.js"; +import { recentVisitsRoutes } from "./routes/recentVisits.js"; +import { settingsRoutes } from "./routes/settings.js"; +import { readLaterRoutes } from "./routes/readLater.js"; import { loadPlugins } from "./plugins/loader.js"; import * as serviceRepo from "./db/repositories/services.js"; import * as bookmarkRepo from "./db/repositories/bookmarks.js"; @@ -64,7 +68,11 @@ async function main() { await app.register(logRoutes); await app.register(pluginRoutes); await app.register(resetRoutes); + await app.register(transferRoutes); await app.register(bookmarkRoutes); + await app.register(recentVisitsRoutes); + await app.register(settingsRoutes); + await app.register(readLaterRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/bookmarks.ts b/apps/backend/src/routes/bookmarks.ts index 8dfa79a..27ad677 100644 --- a/apps/backend/src/routes/bookmarks.ts +++ b/apps/backend/src/routes/bookmarks.ts @@ -26,15 +26,19 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise { let displayName = parsed.data.displayName; let favicon: string | null = null; + let description = parsed.data.description; - // Titel/Favicon automatisch ziehen, falls kein Name angegeben wurde oder - // schlicht um ein Favicon zu bekommen (derselbe Mechanismus wie beim - // Netzwerk-Scanner, siehe apps/backend/src/scanner/http.ts). + // Titel/Favicon/Beschreibung automatisch ziehen, falls nicht angegeben + // (derselbe Mechanismus wie beim Netzwerk-Scanner, siehe + // apps/backend/src/scanner/http.ts). try { const probe = await probeHttp(parsed.data.url, 5000); if (!displayName) { displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url); } + if (!description) { + description = probe.description; + } favicon = probe.faviconUrl ?? null; } catch { if (!displayName) { @@ -46,7 +50,10 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise { categoryRepo.ensureCategory(parsed.data.category); } - const bookmark = bookmarkRepo.createBookmark(parsed.data, { displayName, favicon }); + const bookmark = bookmarkRepo.createBookmark( + { ...parsed.data, description }, + { displayName, favicon } + ); return reply.code(201).send(bookmark); }); diff --git a/apps/backend/src/routes/readLater.ts b/apps/backend/src/routes/readLater.ts new file mode 100644 index 0000000..97d43a5 --- /dev/null +++ b/apps/backend/src/routes/readLater.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance } from "fastify"; +import * as readLaterRepo from "../db/repositories/readLater.js"; +import * as bookmarkRepo from "../db/repositories/bookmarks.js"; +import { probeHttp } from "../scanner/http.js"; + +export async function readLaterRoutes(app: FastifyInstance): Promise { + app.get("/api/read-later", async () => { + return readLaterRepo.listReadLater(); + }); + + app.post("/api/read-later", async (request, reply) => { + const body = request.body as { url?: string } | undefined; + if (!body?.url) { + return reply.code(400).send({ error: "url erforderlich" }); + } + + let name: string; + let favicon: string | null = null; + try { + const probe = await probeHttp(body.url, 5000); + name = probe.title ?? new URL(body.url).hostname; + favicon = probe.faviconUrl ?? null; + } catch { + try { + name = new URL(body.url).hostname; + } catch { + name = body.url; + } + } + + const item = readLaterRepo.createReadLaterItem({ url: body.url, displayName: name, favicon }); + return reply.code(201).send(item); + }); + + app.patch("/api/read-later/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const body = request.body as { url?: string; displayName?: string } | undefined; + const item = readLaterRepo.updateReadLaterItem(id, body ?? {}); + if (!item) { + return reply.code(404).send({ error: "Eintrag nicht gefunden" }); + } + return item; + }); + + app.delete("/api/read-later/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = readLaterRepo.deleteReadLaterItem(id); + if (!deleted) { + return reply.code(404).send({ error: "Eintrag nicht gefunden" }); + } + return reply.code(204).send(); + }); + + // Verschiebt einen "Später lesen"-Eintrag in die Lesezeichen (als Favorit) + // und entfernt ihn aus der Liste. + app.post("/api/read-later/:id/promote", async (request, reply) => { + const { id } = request.params as { id: string }; + const item = readLaterRepo.getReadLaterItem(id); + if (!item) { + return reply.code(404).send({ error: "Eintrag nicht gefunden" }); + } + + const bookmark = bookmarkRepo.createBookmark( + { url: item.url, favorite: true }, + { displayName: item.displayName, favicon: item.favicon } + ); + readLaterRepo.deleteReadLaterItem(id); + + return reply.code(201).send(bookmark); + }); +} diff --git a/apps/backend/src/routes/recentVisits.ts b/apps/backend/src/routes/recentVisits.ts new file mode 100644 index 0000000..35138cb --- /dev/null +++ b/apps/backend/src/routes/recentVisits.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from "fastify"; +import * as recentVisitsRepo from "../db/repositories/recentVisits.js"; + +export async function recentVisitsRoutes(app: FastifyInstance): Promise { + app.get("/api/recent-visits", async (request) => { + const query = request.query as { limit?: string }; + const limit = query.limit ? Number(query.limit) : undefined; + return recentVisitsRepo.listRecentVisits(limit); + }); + + app.post("/api/recent-visits", async (request, reply) => { + const body = request.body as { itemType?: string; itemId?: string } | undefined; + if (body?.itemType !== "service" && body?.itemType !== "bookmark") { + return reply.code(400).send({ error: "itemType muss 'service' oder 'bookmark' sein" }); + } + if (!body.itemId) { + return reply.code(400).send({ error: "itemId erforderlich" }); + } + recentVisitsRepo.recordVisit(body.itemType, body.itemId); + return reply.code(201).send({ ok: true }); + }); + + app.delete("/api/recent-visits", async () => { + recentVisitsRepo.clearRecentVisits(); + return { ok: true }; + }); +} diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 59788db..cd65b37 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -21,6 +21,10 @@ export async function scanRoutes(app: FastifyInstance): Promise { } try { + // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu + // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). + const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); + const discovered = await scanDeviceServices(device); // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit @@ -60,11 +64,17 @@ export async function scanRoutes(app: FastifyInstance): Promise { const updated = results.filter((r) => !r.created).length; const ports = discovered.map((d) => d.port).sort((a, b) => a - b); + // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden + // (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern + // zur manuellen Durchsicht zurückgegeben (siehe Admin -> Scanner). + const foundPorts = new Set(discovered.map((d) => d.port)); + const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port)); + logRepo.logScan({ type: "device", targetId: device.id, level: "info", - message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert`, + message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}`, }); return { @@ -74,6 +84,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { created, updated, services: results.map((r) => r.service), + staleServices, }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); @@ -106,6 +117,8 @@ export async function scanRoutes(app: FastifyInstance): Promise { const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000; try { + const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox"); + const hosts = await fetchFritzBoxHosts({ host, port, username, password }); const devices = hosts.map((h) => deviceRepo.upsertDeviceFromScan({ @@ -117,13 +130,19 @@ export async function scanRoutes(app: FastifyInstance): Promise { }) ); + // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht + // mehr in der Liste sind – nicht automatisch gelöscht, nur zur + // manuellen Durchsicht zurückgegeben. + const foundIps = new Set(hosts.map((h) => h.ip)); + const staleDevices = devicesBeforeScan.filter((d) => !foundIps.has(d.ip)); + logRepo.logScan({ type: "fritzbox", level: "info", - message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`, + message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}`, }); - return { found: hosts.length, devices }; + return { found: hosts.length, devices, staleDevices }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts index e3cd916..88d2e7c 100644 --- a/apps/backend/src/routes/services.ts +++ b/apps/backend/src/routes/services.ts @@ -1,9 +1,7 @@ import type { FastifyInstance } from "fastify"; -import * as XLSX from "xlsx"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; -import * as categoryRepo from "../db/repositories/categories.js"; interface ServiceListQuery { deviceId?: string; @@ -11,45 +9,6 @@ interface ServiceListQuery { favorite?: string; } -interface ExportRow { - displayName: string; - category: string; - alias: string; - favorite: string; - visible: string; - order: number; - hostname: string; - port: number; - https: string; - url: string; - deviceHostname: string; - deviceIp: string; -} - -function buildExportRows(): ExportRow[] { - const services = serviceRepo.listServices(); - const devices = deviceRepo.listDevices(); - const deviceById = new Map(devices.map((d) => [d.id, d])); - - return services.map((s) => { - const device = deviceById.get(s.deviceId); - return { - displayName: s.displayName, - category: s.category ?? "", - alias: s.alias.join(";"), - favorite: s.favorite ? "true" : "false", - visible: s.visible ? "true" : "false", - order: s.order, - hostname: s.hostname, - port: s.port, - https: s.https ? "true" : "false", - url: s.url, - deviceHostname: device?.hostname ?? "", - deviceIp: device?.ip ?? "", - }; - }); -} - export async function serviceRoutes(app: FastifyInstance): Promise { app.get("/api/services", async (request) => { const query = request.query as ServiceListQuery; @@ -84,136 +43,6 @@ export async function serviceRoutes(app: FastifyInstance): Promise { return reply.code(201).send(service); }); - // Müssen vor der /:id-Route stehen, damit "export"/"import" nicht als ID interpretiert wird. - app.get("/api/services/export", async (request, reply) => { - const query = request.query as { format?: string }; - const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv"; - const rows = buildExportRows(); - - if (format === "json") { - reply.header("Content-Disposition", 'attachment; filename="launchpad-services.json"'); - reply.type("application/json"); - return rows; - } - - const worksheet = XLSX.utils.json_to_sheet(rows); - - if (format === "xlsx") { - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, "Dienste"); - const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer; - reply.header("Content-Disposition", 'attachment; filename="launchpad-services.xlsx"'); - reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); - return reply.send(buffer); - } - - const csv = XLSX.utils.sheet_to_csv(worksheet); - reply.header("Content-Disposition", 'attachment; filename="launchpad-services.csv"'); - reply.type("text/csv; charset=utf-8"); - return reply.send(csv); - }); - - app.post("/api/services/import", async (request, reply) => { - const body = request.body as { format?: string; content?: string } | undefined; - if (!body?.content) { - return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" }); - } - - let rows: Record[]; - try { - if (body.format === "json") { - const text = Buffer.from(body.content, "base64").toString("utf-8"); - rows = JSON.parse(text); - } else { - const buffer = Buffer.from(body.content, "base64"); - const workbook = XLSX.read(buffer, { type: "buffer" }); - const sheetName = workbook.SheetNames[0]; - rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" }); - } - } catch (err) { - return reply.code(400).send({ - error: "Datei konnte nicht gelesen werden", - detail: err instanceof Error ? err.message : String(err), - }); - } - - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (const row of rows) { - try { - const deviceHostname = String(row.deviceHostname ?? "").trim(); - const deviceIp = String(row.deviceIp ?? "").trim(); - const hostname = String(row.hostname ?? deviceHostname).trim(); - const port = Number(row.port); - const displayName = String(row.displayName ?? "").trim(); - const url = String(row.url ?? "").trim(); - - if (!displayName || !hostname || !port || !url) { - errors.push(`Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`); - continue; - } - - // Gerät über IP finden, sonst über Hostname, sonst neu anlegen. - const allDevices = deviceRepo.listDevices(); - let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined; - if (!device && deviceHostname) { - device = allDevices.find((d) => d.hostname === deviceHostname); - } - if (!device) { - device = deviceRepo.createDevice({ - hostname: deviceHostname || hostname, - ip: deviceIp || hostname, - }); - } - - // Bereits vorhanden (gleiches Gerät + Port)? -> überspringen, nicht - // doppelt anlegen und nichts Bestehendes verändern/löschen. - const existing = serviceRepo.listServicesByDevice(device.id).find((s) => s.port === port); - if (existing) { - skipped++; - continue; - } - - const category = row.category ? String(row.category).trim() : undefined; - - serviceRepo.createService({ - deviceId: device.id, - displayName, - hostname, - url, - https: String(row.https ?? "").toLowerCase() === "true", - port, - category: category || undefined, - alias: row.alias - ? String(row.alias) - .split(";") - .map((a) => a.trim()) - .filter(Boolean) - : undefined, - favorite: String(row.favorite ?? "").toLowerCase() === "true", - order: - row.order !== undefined && row.order !== "" ? Number(row.order) : undefined, - visible: - row.visible !== undefined && row.visible !== "" - ? String(row.visible).toLowerCase() === "true" - : undefined, - }); - - if (category) { - categoryRepo.ensureCategory(category); - } - - imported++; - } catch (err) { - errors.push(err instanceof Error ? err.message : String(err)); - } - } - - return { imported, skipped, errors }; - }); - // Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird. app.patch("/api/services/reorder", async (request, reply) => { const parsed = ServiceReorderSchema.safeParse(request.body); diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts new file mode 100644 index 0000000..4bb4fbb --- /dev/null +++ b/apps/backend/src/routes/settings.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from "fastify"; +import * as settingsRepo from "../db/repositories/settings.js"; + +export async function settingsRoutes(app: FastifyInstance): Promise { + app.get("/api/settings", async () => { + const all = settingsRepo.listSettings(); + return { + recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), + }; + }); + + app.patch("/api/settings", async (request, reply) => { + const body = request.body as { recentVisitsLimit?: number } | undefined; + + if (body?.recentVisitsLimit !== undefined) { + const value = Number(body.recentVisitsLimit); + if (!Number.isFinite(value) || value < 0 || value > 50) { + return reply.code(400).send({ error: "recentVisitsLimit muss zwischen 0 und 50 liegen" }); + } + settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value))); + } + + const all = settingsRepo.listSettings(); + return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5) }; + }); +} diff --git a/apps/backend/src/routes/transfer.ts b/apps/backend/src/routes/transfer.ts new file mode 100644 index 0000000..7e93a32 --- /dev/null +++ b/apps/backend/src/routes/transfer.ts @@ -0,0 +1,250 @@ +import type { FastifyInstance } from "fastify"; +import * as XLSX from "xlsx"; +import * as deviceRepo from "../db/repositories/devices.js"; +import * as serviceRepo from "../db/repositories/services.js"; +import * as categoryRepo from "../db/repositories/categories.js"; + +interface TransferRow { + type: "device" | "service"; + displayName: string; + hostname: string; + ip: string; + mac: string; + manufacturer: string; + model: string; + category: string; + alias: string; + favorite: string; + visible: string; + order: number | string; + port: string | number; + https: string; + url: string; + deviceHostname: string; + deviceIp: string; +} + +function buildExportRows(): TransferRow[] { + const devices = deviceRepo.listDevices(); + const services = serviceRepo.listServices(); + const deviceById = new Map(devices.map((d) => [d.id, d])); + + const deviceRows: TransferRow[] = devices.map((d) => ({ + type: "device", + displayName: d.hostname, + hostname: d.hostname, + ip: d.ip, + mac: d.mac ?? "", + manufacturer: d.manufacturer ?? "", + model: d.model ?? "", + category: "", + alias: "", + favorite: "", + visible: "", + order: "", + port: "", + https: "", + url: "", + deviceHostname: "", + deviceIp: "", + })); + + const serviceRows: TransferRow[] = services.map((s) => { + const device = deviceById.get(s.deviceId); + return { + type: "service", + displayName: s.displayName, + hostname: s.hostname, + ip: "", + mac: "", + manufacturer: "", + model: "", + category: s.category ?? "", + alias: s.alias.join(";"), + favorite: s.favorite ? "true" : "false", + visible: s.visible ? "true" : "false", + order: s.order, + port: s.port, + https: s.https ? "true" : "false", + url: s.url, + deviceHostname: device?.hostname ?? "", + deviceIp: device?.ip ?? "", + }; + }); + + return [...deviceRows, ...serviceRows]; +} + +export async function transferRoutes(app: FastifyInstance): Promise { + app.get("/api/transfer/export", async (request, reply) => { + const query = request.query as { format?: string }; + const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv"; + const rows = buildExportRows(); + + if (format === "json") { + reply.header("Content-Disposition", 'attachment; filename="launchpad-export.json"'); + reply.type("application/json"); + return rows; + } + + const worksheet = XLSX.utils.json_to_sheet(rows); + + if (format === "xlsx") { + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "LaunchPad"); + const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer; + reply.header("Content-Disposition", 'attachment; filename="launchpad-export.xlsx"'); + reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + return reply.send(buffer); + } + + const csv = XLSX.utils.sheet_to_csv(worksheet); + reply.header("Content-Disposition", 'attachment; filename="launchpad-export.csv"'); + reply.type("text/csv; charset=utf-8"); + return reply.send(csv); + }); + + app.post("/api/transfer/import", async (request, reply) => { + const body = request.body as { format?: string; content?: string } | undefined; + if (!body?.content) { + return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" }); + } + + let rows: Record[]; + try { + if (body.format === "json") { + const text = Buffer.from(body.content, "base64").toString("utf-8"); + rows = JSON.parse(text); + } else { + const buffer = Buffer.from(body.content, "base64"); + const workbook = XLSX.read(buffer, { type: "buffer" }); + const sheetName = workbook.SheetNames[0]; + rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" }); + } + } catch (err) { + return reply.code(400).send({ + error: "Datei konnte nicht gelesen werden", + detail: err instanceof Error ? err.message : String(err), + }); + } + + let devicesImported = 0; + let devicesSkipped = 0; + let servicesImported = 0; + let servicesSkipped = 0; + const errors: string[] = []; + + // Geräte-Zeilen zuerst verarbeiten, damit Dienst-Zeilen aus derselben + // Datei sie bereits über deviceHostname/deviceIp finden können. + const deviceRows = rows.filter((r) => String(r.type ?? "").trim() === "device"); + const serviceRows = rows.filter((r) => String(r.type ?? "").trim() === "service"); + + for (const row of deviceRows) { + try { + const hostname = String(row.hostname ?? row.displayName ?? "").trim(); + const ip = String(row.ip ?? "").trim(); + if (!hostname || !ip) { + errors.push(`Geräte-Zeile übersprungen (hostname/ip fehlt): ${JSON.stringify(row)}`); + continue; + } + + const allDevices = deviceRepo.listDevices(); + const existing = + allDevices.find((d) => d.ip === ip) ?? allDevices.find((d) => d.hostname === hostname); + + if (existing) { + devicesSkipped++; + continue; + } + + deviceRepo.createDevice({ + hostname, + ip, + mac: row.mac ? String(row.mac).trim() : undefined, + manufacturer: row.manufacturer ? String(row.manufacturer).trim() : undefined, + model: row.model ? String(row.model).trim() : undefined, + }); + devicesImported++; + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + + for (const row of serviceRows) { + try { + const deviceHostname = String(row.deviceHostname ?? "").trim(); + const deviceIp = String(row.deviceIp ?? "").trim(); + const hostname = String(row.hostname ?? deviceHostname).trim(); + const port = Number(row.port); + const displayName = String(row.displayName ?? "").trim(); + const url = String(row.url ?? "").trim(); + + if (!displayName || !hostname || !port || !url) { + errors.push(`Dienst-Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`); + continue; + } + + const allDevices = deviceRepo.listDevices(); + let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined; + if (!device && deviceHostname) { + device = allDevices.find((d) => d.hostname === deviceHostname); + } + if (!device) { + device = deviceRepo.createDevice({ + hostname: deviceHostname || hostname, + ip: deviceIp || hostname, + }); + devicesImported++; + } + + const existingService = serviceRepo + .listServicesByDevice(device.id) + .find((s) => s.port === port); + if (existingService) { + servicesSkipped++; + continue; + } + + const category = row.category ? String(row.category).trim() : undefined; + + serviceRepo.createService({ + deviceId: device.id, + displayName, + hostname, + url, + https: String(row.https ?? "").toLowerCase() === "true", + port, + category: category || undefined, + alias: row.alias + ? String(row.alias) + .split(";") + .map((a) => a.trim()) + .filter(Boolean) + : undefined, + favorite: String(row.favorite ?? "").toLowerCase() === "true", + order: row.order !== undefined && row.order !== "" ? Number(row.order) : undefined, + visible: + row.visible !== undefined && row.visible !== "" + ? String(row.visible).toLowerCase() === "true" + : undefined, + }); + + if (category) { + categoryRepo.ensureCategory(category); + } + + servicesImported++; + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + + return { + devicesImported, + devicesSkipped, + servicesImported, + servicesSkipped, + errors, + }; + }); +} diff --git a/apps/backend/src/scanner/http.ts b/apps/backend/src/scanner/http.ts index c715937..b14e73d 100644 --- a/apps/backend/src/scanner/http.ts +++ b/apps/backend/src/scanner/http.ts @@ -6,6 +6,7 @@ export interface HttpProbeResult { status?: number; title?: string; faviconUrl?: string; + description?: string; server?: string; bodySnippet?: string; } @@ -46,6 +47,7 @@ export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise in beiden Attribut-Reihenfolgen + const match = + html.match(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ?? + html.match(/]+content=["']([^"']*)["'][^>]+name=["']description["']/i); + const description = match?.[1]?.trim(); + return description ? description : undefined; +} diff --git a/apps/frontend/docker-entrypoint.sh b/apps/frontend/docker-entrypoint.sh index 25280ea..afbcf98 100644 --- a/apps/frontend/docker-entrypoint.sh +++ b/apps/frontend/docker-entrypoint.sh @@ -8,9 +8,22 @@ CERT_FILE="$CERT_DIR/fullchain.pem" KEY_FILE="$CERT_DIR/privkey.pem" CERT_HOST_FILE="$CERT_DIR/.cert-host" HOST="${LAUNCHPAD_HOST:-localhost}" +EXTRA_HOST="${LAUNCHPAD_EXTRA_HOST:-}" mkdir -p "$CERT_DIR" +# Baut einen SAN-Eintrag (IP:... oder DNS:...) für einen einzelnen Hostwert. +san_entry_for() { + case "$1" in + *[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*) + echo "IP:$1" + ;; + *) + echo "DNS:$1" + ;; + esac +} + if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && [ ! -f "$CA_CERT" ]; then # fullchain.pem/privkey.pem wurden vom Nutzer eingebunden (z. B. eigenes # Zertifikat) und es gibt keine von uns erzeugte CA dazu -> nichts anfassen. @@ -27,24 +40,22 @@ else -out "$CA_CERT" 2>/dev/null fi - # Server-Zertifikat nur neu erzeugen, wenn es noch keins gibt oder sich - # LAUNCHPAD_HOST geändert hat (dann würde die alte SAN nicht mehr passen). + # Kombinierte Kennung aus HOST + EXTRA_HOST, um zu erkennen ob sich einer + # von beiden geändert hat -> dann Server-Zertifikat neu ausstellen. Die CA + # selbst bleibt davon unberührt (bereits erteiltes Gerätevertrauen gilt weiter). + HOST_SIGNATURE="$HOST|$EXTRA_HOST" NEED_NEW_LEAF=false if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then NEED_NEW_LEAF=true - elif [ "$(cat "$CERT_HOST_FILE" 2>/dev/null)" != "$HOST" ]; then + elif [ "$(cat "$CERT_HOST_FILE" 2>/dev/null)" != "$HOST_SIGNATURE" ]; then NEED_NEW_LEAF=true fi if [ "$NEED_NEW_LEAF" = "true" ]; then - case "$HOST" in - *[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*) - SAN="IP:$HOST,DNS:localhost,IP:127.0.0.1" - ;; - *) - SAN="DNS:$HOST,DNS:localhost,IP:127.0.0.1" - ;; - esac + SAN="$(san_entry_for "$HOST"),DNS:localhost,IP:127.0.0.1" + if [ -n "$EXTRA_HOST" ] && [ "$EXTRA_HOST" != "$HOST" ]; then + SAN="$SAN,$(san_entry_for "$EXTRA_HOST")" + fi EXTFILE=$(mktemp) printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n" "$SAN" > "$EXTFILE" @@ -56,8 +67,8 @@ else -out "$CERT_FILE" 2>/dev/null rm -f "$EXTFILE" "$CERT_DIR/server.csr" - echo "$HOST" > "$CERT_HOST_FILE" - echo "[entrypoint] Server-Zertifikat für '$HOST' erzeugt und mit lokaler CA signiert." + echo "$HOST_SIGNATURE" > "$CERT_HOST_FILE" + echo "[entrypoint] Server-Zertifikat erzeugt (SAN: $SAN), mit lokaler CA signiert." fi fi diff --git a/apps/frontend/src/hooks/useReadLater.ts b/apps/frontend/src/hooks/useReadLater.ts new file mode 100644 index 0000000..280cdb5 --- /dev/null +++ b/apps/frontend/src/hooks/useReadLater.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +export interface ReadLaterItem { + id: string; + url: string; + displayName: string; + favicon: string | null; + savedAt: string; +} + +async function fetchReadLater(): Promise { + const res = await fetch("/api/read-later"); + if (!res.ok) { + throw new Error(`Später-lesen-Liste konnte nicht geladen werden (HTTP ${res.status})`); + } + return res.json(); +} + +export function useReadLater() { + return useQuery({ + queryKey: ["read-later"], + queryFn: fetchReadLater, + }); +} diff --git a/apps/frontend/src/hooks/useRecentVisits.ts b/apps/frontend/src/hooks/useRecentVisits.ts new file mode 100644 index 0000000..eed1bca --- /dev/null +++ b/apps/frontend/src/hooks/useRecentVisits.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Bookmark, Service } from "@launchpad/shared"; + +export type RecentVisitItem = + | (Service & { kind: "service" }) + | (Bookmark & { kind: "bookmark" }); + +async function fetchRecentVisits(): Promise { + const res = await fetch("/api/recent-visits"); + if (!res.ok) { + throw new Error(`Zuletzt besucht konnte nicht geladen werden (HTTP ${res.status})`); + } + return res.json(); +} + +export function useRecentVisits() { + return useQuery({ + queryKey: ["recent-visits"], + queryFn: fetchRecentVisits, + }); +} + +export async function recordVisit(itemType: "service" | "bookmark", itemId: string): Promise { + await fetch("/api/recent-visits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemType, itemId }), + }); +} diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..152f46d --- /dev/null +++ b/apps/frontend/src/hooks/useSettings.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +export interface AppSettings { + recentVisitsLimit: number; +} + +async function fetchSettings(): Promise { + const res = await fetch("/api/settings"); + if (!res.ok) { + throw new Error(`Einstellungen konnten nicht geladen werden (HTTP ${res.status})`); + } + return res.json(); +} + +export function useSettings() { + return useQuery({ + queryKey: ["settings"], + queryFn: fetchSettings, + }); +} diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 42dbab4..466b13a 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -16,3 +16,17 @@ body { "Segoe UI", sans-serif; } + +/* + * Sagt dem Browser, dass native Formularelemente (select/option, Checkbox, + * Datepicker …) im jeweiligen Theme gerendert werden sollen. Ohne das + * bleiben z. B.