From dc91a9aba9ffd5d9fe6785140b877ce790656db7 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 19 Jul 2026 21:56:25 +0200 Subject: [PATCH] Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown --- README.md | 12 + apps/backend/package.json | 3 +- apps/backend/src/db/client.ts | 16 + apps/backend/src/db/repositories/bookmarks.ts | 115 +++++ apps/backend/src/db/schema.ts | 22 + apps/backend/src/index.ts | 11 +- apps/backend/src/routes/bookmarks.ts | 86 ++++ apps/backend/src/routes/scan.ts | 4 +- apps/backend/src/routes/services.ts | 171 +++++++ apps/frontend/src/hooks/useBookmarks.ts | 17 + apps/frontend/src/router.tsx | 8 + apps/frontend/src/routes/HomePage.tsx | 125 +++-- .../frontend/src/routes/admin/AdminLayout.tsx | 1 + .../src/routes/admin/BookmarksPage.tsx | 460 ++++++++++++++++++ .../frontend/src/routes/admin/DevicesPage.tsx | 6 +- .../src/routes/admin/ServicesPage.tsx | 265 +++++++++- docs/ROADMAP.md | 40 ++ packages/shared/src/index.ts | 71 ++- packages/shared/src/schemas.ts | 28 ++ packages/ui/src/Favicon.tsx | 41 ++ packages/ui/src/FavoritesBar.tsx | 126 +++-- packages/ui/src/ResultsList.tsx | 72 +-- packages/ui/src/index.ts | 3 + pnpm-lock.yaml | 132 +++-- 24 files changed, 1655 insertions(+), 180 deletions(-) create mode 100644 apps/backend/src/db/repositories/bookmarks.ts create mode 100644 apps/backend/src/routes/bookmarks.ts create mode 100644 apps/frontend/src/hooks/useBookmarks.ts create mode 100644 apps/frontend/src/routes/admin/BookmarksPage.tsx create mode 100644 packages/ui/src/Favicon.tsx diff --git a/README.md b/README.md index f581274..84928d5 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,17 @@ 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 +PATCH /api/bookmarks/reorder Body: [{ id, order }, ...] +PATCH /api/bookmarks/:id +DELETE /api/bookmarks/:id GET /api/categories POST /api/categories @@ -229,6 +240,7 @@ POST /api/plugins/:name/import löst importDevices() eines Plugins aus /admin/dashboard /admin/devices /admin/services +/admin/bookmarks /admin/categories /admin/scanner /admin/plugins Hinweis: Plugin-System noch nicht gebaut diff --git a/apps/backend/package.json b/apps/backend/package.json index 43e2d82..67174ea 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,7 +17,8 @@ "better-sqlite3": "^11.3.0", "dotenv": "^16.4.5", "drizzle-orm": "^0.33.0", - "fastify": "^4.28.1" + "fastify": "^4.28.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 5678502..4a17f55 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -76,6 +76,22 @@ export function ensureSchema(): void { message TEXT NOT NULL, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS bookmarks ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + display_name TEXT NOT NULL, + hostname TEXT NOT NULL, + description TEXT, + category TEXT, + icon TEXT, + favicon TEXT, + favorite INTEGER NOT NULL DEFAULT 0, + alias TEXT NOT NULL DEFAULT '[]', + "order" REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); `); // Leichte Migration für Datenbanken, die vor Einführung von "visible" diff --git a/apps/backend/src/db/repositories/bookmarks.ts b/apps/backend/src/db/repositories/bookmarks.ts new file mode 100644 index 0000000..0bd4ad4 --- /dev/null +++ b/apps/backend/src/db/repositories/bookmarks.ts @@ -0,0 +1,115 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { Bookmark, BookmarkCreateInput, BookmarkUpdateInput } from "@launchpad/shared"; +import { db } from "../client.js"; +import { bookmarks } from "../schema.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function mapRow(row: typeof bookmarks.$inferSelect): Bookmark { + return { + id: row.id, + url: row.url, + displayName: row.displayName, + hostname: row.hostname, + description: row.description, + category: row.category, + icon: row.icon, + favicon: row.favicon, + favorite: row.favorite, + alias: JSON.parse(row.alias) as string[], + order: row.order, + }; +} + +export function extractHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export function listBookmarks(): Bookmark[] { + return db.select().from(bookmarks).all().map(mapRow); +} + +export function getBookmark(id: string): Bookmark | null { + const row = db.select().from(bookmarks).where(eq(bookmarks.id, id)).get(); + return row ? mapRow(row) : null; +} + +export interface CreateBookmarkOptions { + displayName: string; + favicon?: string | null; +} + +export function createBookmark( + input: BookmarkCreateInput, + resolved: CreateBookmarkOptions +): Bookmark { + const id = randomUUID(); + const timestamp = nowIso(); + + db.insert(bookmarks) + .values({ + id, + url: input.url, + displayName: resolved.displayName, + hostname: extractHostname(input.url), + description: input.description ?? null, + category: input.category ?? null, + icon: input.icon ?? null, + favicon: resolved.favicon ?? null, + favorite: input.favorite ?? false, + alias: JSON.stringify(input.alias ?? []), + order: input.order ?? 0, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return getBookmark(id)!; +} + +export function updateBookmark(id: string, input: BookmarkUpdateInput): Bookmark | null { + const existing = getBookmark(id); + if (!existing) return null; + + db.update(bookmarks) + .set({ + ...(input.url !== undefined && { url: input.url, hostname: extractHostname(input.url) }), + ...(input.displayName !== undefined && { displayName: input.displayName }), + ...(input.description !== undefined && { description: input.description }), + ...(input.category !== undefined && { category: input.category }), + ...(input.icon !== undefined && { icon: input.icon }), + ...(input.favorite !== undefined && { favorite: input.favorite }), + ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), + ...(input.order !== undefined && { order: input.order }), + updatedAt: nowIso(), + }) + .where(eq(bookmarks.id, id)) + .run(); + + return getBookmark(id); +} + +export function deleteBookmark(id: string): boolean { + const result = db.delete(bookmarks).where(eq(bookmarks.id, id)).run(); + return result.changes > 0; +} + +export function reorderBookmarks(input: { id: string; order: number }[]): Bookmark[] { + db.transaction((tx) => { + for (const entry of input) { + tx.update(bookmarks) + .set({ order: entry.order, updatedAt: nowIso() }) + .where(eq(bookmarks.id, entry.id)) + .run(); + } + }); + + return listBookmarks(); +} diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 1724c60..85f2d11 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -75,3 +75,25 @@ export const scanLogs = sqliteTable("scan_logs", { message: text("message").notNull(), createdAt: text("created_at").notNull(), }); + +/** + * Manuell angelegtes Lesezeichen – im Unterschied zu Diensten nicht an ein + * gescanntes Gerät gebunden, eigenständige Tabelle. Erscheint zusammen mit + * Diensten in der Suche (siehe packages/shared rankServices), wird aber + * separat verwaltet (Admin -> Lesezeichen). + */ +export const bookmarks = sqliteTable("bookmarks", { + id: text("id").primaryKey(), + url: text("url").notNull(), + displayName: text("display_name").notNull(), + hostname: text("hostname").notNull(), // aus der URL abgeleitet + description: text("description"), + category: text("category"), + icon: text("icon"), + favicon: text("favicon"), + favorite: integer("favorite", { mode: "boolean" }).notNull().default(false), + alias: text("alias").notNull().default("[]"), + order: real("order").notNull().default(0), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 762d21e..47e05c6 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -10,8 +10,10 @@ 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 { bookmarkRoutes } from "./routes/bookmarks.js"; import { loadPlugins } from "./plugins/loader.js"; import * as serviceRepo from "./db/repositories/services.js"; +import * as bookmarkRepo from "./db/repositories/bookmarks.js"; import * as categoryRepo from "./db/repositories/categories.js"; const PORT = Number(process.env.PORT ?? 3001); @@ -40,10 +42,10 @@ async function main() { // der Suche als Kategorie auftaucht. const existingCategoryNames = Array.from( new Set( - serviceRepo - .listServices() - .map((s) => s.category) - .filter((c): c is string => !!c) + [ + ...serviceRepo.listServices().map((s) => s.category), + ...bookmarkRepo.listBookmarks().map((b) => b.category), + ].filter((c): c is string => !!c) ) ); const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames); @@ -62,6 +64,7 @@ async function main() { await app.register(logRoutes); await app.register(pluginRoutes); await app.register(resetRoutes); + await app.register(bookmarkRoutes); 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 new file mode 100644 index 0000000..8dfa79a --- /dev/null +++ b/apps/backend/src/routes/bookmarks.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from "fastify"; +import { BookmarkCreateSchema, BookmarkReorderSchema, BookmarkUpdateSchema } from "@launchpad/shared"; +import * as bookmarkRepo from "../db/repositories/bookmarks.js"; +import * as categoryRepo from "../db/repositories/categories.js"; +import { probeHttp } from "../scanner/http.js"; + +export async function bookmarkRoutes(app: FastifyInstance): Promise { + app.get("/api/bookmarks", async () => { + return bookmarkRepo.listBookmarks(); + }); + + app.get("/api/bookmarks/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const bookmark = bookmarkRepo.getBookmark(id); + if (!bookmark) { + return reply.code(404).send({ error: "Lesezeichen nicht gefunden" }); + } + return bookmark; + }); + + app.post("/api/bookmarks", async (request, reply) => { + const parsed = BookmarkCreateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + + let displayName = parsed.data.displayName; + let favicon: string | null = null; + + // 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). + try { + const probe = await probeHttp(parsed.data.url, 5000); + if (!displayName) { + displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url); + } + favicon = probe.faviconUrl ?? null; + } catch { + if (!displayName) { + displayName = bookmarkRepo.extractHostname(parsed.data.url); + } + } + + if (parsed.data.category) { + categoryRepo.ensureCategory(parsed.data.category); + } + + const bookmark = bookmarkRepo.createBookmark(parsed.data, { displayName, favicon }); + return reply.code(201).send(bookmark); + }); + + // Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird. + app.patch("/api/bookmarks/reorder", async (request, reply) => { + const parsed = BookmarkReorderSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + return bookmarkRepo.reorderBookmarks(parsed.data); + }); + + app.patch("/api/bookmarks/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = BookmarkUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + if (parsed.data.category) { + categoryRepo.ensureCategory(parsed.data.category); + } + const bookmark = bookmarkRepo.updateBookmark(id, parsed.data); + if (!bookmark) { + return reply.code(404).send({ error: "Lesezeichen nicht gefunden" }); + } + return bookmark; + }); + + app.delete("/api/bookmarks/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = bookmarkRepo.deleteBookmark(id); + if (!deleted) { + return reply.code(404).send({ error: "Lesezeichen nicht gefunden" }); + } + return reply.code(204).send(); + }); +} diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 88ca2f3..59788db 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -58,17 +58,19 @@ export async function scanRoutes(app: FastifyInstance): Promise { const created = results.filter((r) => r.created).length; const updated = results.filter((r) => !r.created).length; + const ports = discovered.map((d) => d.port).sort((a, b) => a - b); logRepo.logScan({ type: "device", targetId: device.id, level: "info", - message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden, ${created} neu, ${updated} aktualisiert`, + message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert`, }); return { deviceId: device.id, scannedPorts: discovered.length, + ports, created, updated, services: results.map((r) => r.service), diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts index 88d2e7c..e3cd916 100644 --- a/apps/backend/src/routes/services.ts +++ b/apps/backend/src/routes/services.ts @@ -1,7 +1,9 @@ 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; @@ -9,6 +11,45 @@ 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; @@ -43,6 +84,136 @@ 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/frontend/src/hooks/useBookmarks.ts b/apps/frontend/src/hooks/useBookmarks.ts new file mode 100644 index 0000000..526e88f --- /dev/null +++ b/apps/frontend/src/hooks/useBookmarks.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Bookmark } from "@launchpad/shared"; + +async function fetchBookmarks(): Promise { + const res = await fetch("/api/bookmarks"); + if (!res.ok) { + throw new Error(`Lesezeichen konnten nicht geladen werden (HTTP ${res.status})`); + } + return res.json(); +} + +export function useBookmarks() { + return useQuery({ + queryKey: ["bookmarks"], + queryFn: fetchBookmarks, + }); +} diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx index c633cd1..ba55be0 100644 --- a/apps/frontend/src/router.tsx +++ b/apps/frontend/src/router.tsx @@ -4,6 +4,7 @@ import { AdminLayout } from "./routes/admin/AdminLayout.js"; import { DashboardPage } from "./routes/admin/DashboardPage.js"; import { DevicesPage } from "./routes/admin/DevicesPage.js"; 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 { PluginsPage } from "./routes/admin/PluginsPage.js"; @@ -53,6 +54,12 @@ const adminServicesRoute = createRoute({ component: ServicesPage, }); +const adminBookmarksRoute = createRoute({ + getParentRoute: () => adminRoute, + path: "/bookmarks", + component: BookmarksPage, +}); + const adminCategoriesRoute = createRoute({ getParentRoute: () => adminRoute, path: "/categories", @@ -90,6 +97,7 @@ const routeTree = rootRoute.addChildren([ adminDashboardRoute, adminDevicesRoute, adminServicesRoute, + adminBookmarksRoute, adminCategoriesRoute, adminScannerRoute, adminPluginsRoute, diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index fa43cd6..97a9ddb 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -2,25 +2,38 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui"; -import { rankServices, type Service } from "@launchpad/shared"; +import { rankServices, type SearchResult } from "@launchpad/shared"; import { useServices } from "../hooks/useServices.js"; +import { useBookmarks } from "../hooks/useBookmarks.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useTheme } from "../hooks/useTheme.js"; -function openService(service: Service) { - window.open(service.url, "_blank", "noopener,noreferrer"); +function openItem(item: SearchResult) { + window.open(item.url, "_blank", "noopener,noreferrer"); } -async function toggleServiceFavorite(service: Service): Promise { - const res = await fetch(`/api/services/${service.id}`, { +async function toggleFavoriteRequest(item: SearchResult): Promise { + const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`; + const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ favorite: !service.favorite }), + body: JSON.stringify({ favorite: !item.favorite }), }); if (!res.ok) { throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`); } - return res.json(); +} + +async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise { + const path = kind === "service" ? "/api/services/reorder" : "/api/bookmarks/reorder"; + const res = await fetch(path, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(orderedIds.map((id, index) => ({ id, order: index }))), + }); + if (!res.ok) { + throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`); + } } export function HomePage() { @@ -28,36 +41,56 @@ export function HomePage() { const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const { health, error: healthError } = useBackendHealth(); - const { data: services, isLoading, isError } = useServices(); + const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices(); + const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks(); const inputRef = useRef(null); const queryClient = useQueryClient(); const isSearching = query.trim().length > 0; + const isLoading = servicesLoading || bookmarksLoading; const toggleFavorite = useMutation({ - mutationFn: toggleServiceFavorite, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["services"] }); + mutationFn: toggleFavoriteRequest, + onSuccess: (_data, item) => { + queryClient.invalidateQueries({ queryKey: [item.kind === "service" ? "services" : "bookmarks"] }); }, }); + const reorderServices = useMutation({ + mutationFn: (ids: string[]) => reorderRequest("service", ids), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), + }); + + const reorderBookmarks = useMutation({ + mutationFn: (ids: string[]) => reorderRequest("bookmark", ids), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), + }); + // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer, // siehe Admin -> Dienste) tauchen in der Suche nicht auf. - const visibleServices = useMemo( - () => (services ?? []).filter((s) => s.visible), - [services] - ); + const allItems: SearchResult[] = useMemo(() => { + const visibleServices: SearchResult[] = (services ?? []) + .filter((s) => s.visible) + .map((s) => ({ ...s, kind: "service" as const })); + const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({ + ...b, + kind: "bookmark" as const, + })); + return [...visibleServices, ...bookmarkItems]; + }, [services, bookmarks]); - const results = useMemo( - () => rankServices(visibleServices, query), - [visibleServices, query] - ); + const results = useMemo(() => rankServices(allItems, query), [allItems, query]); const favoriteServices = useMemo( () => - visibleServices - .filter((s) => s.favorite) + (services ?? []) + .filter((s) => s.visible && s.favorite) .sort((a, b) => a.order - b.order), - [visibleServices] + [services] + ); + + const favoriteBookmarks = useMemo( + () => (bookmarks ?? []).filter((b) => b.favorite).sort((a, b) => a.order - b.order), + [bookmarks] ); // Auswahl zurücksetzen, sobald sich die Trefferliste ändert @@ -88,7 +121,7 @@ export function HomePage() { } else if (e.key === "Enter") { e.preventDefault(); const target = results[selectedIndex]; - if (target) openService(target); + if (target) openItem(target); } else if (e.key === "Escape") { inputRef.current?.blur(); setQuery(""); @@ -100,6 +133,7 @@ export function HomePage() { }, [results, selectedIndex, query]); const isOnline = !healthError && health?.status === "ok"; + const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0; return (
@@ -132,9 +166,30 @@ export function HomePage() {
- {favoriteServices.length > 0 ? ( -
- + {hasFavorites ? ( +
+ {favoriteServices.length > 0 ? ( + { + const service = favoriteServices.find((s) => s.id === item.id); + if (service) openItem({ ...service, kind: "service" }); + }} + onReorder={(ids) => reorderServices.mutate(ids)} + /> + ) : null} + {favoriteBookmarks.length > 0 ? ( + { + const bookmark = favoriteBookmarks.find((b) => b.id === item.id); + if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); + }} + onReorder={(ids) => reorderBookmarks.mutate(ids)} + /> + ) : null}
) : null} @@ -142,29 +197,27 @@ export function HomePage() { ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Dienst suchen … z. B. „frigate“" + placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“" hint="⌘K" autoFocus /> {!isSearching ? null : isLoading ? ( -

- Lade Dienste … -

- ) : isError ? ( +

Lade …

+ ) : servicesError ? (

Dienste konnten nicht geladen werden.

) : ( toggleFavorite.mutate(service)} + onOpen={openItem} + onToggleFavorite={(item) => toggleFavorite.mutate(item)} emptyLabel={ - (visibleServices?.length ?? 0) === 0 - ? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu." + allItems.length === 0 + ? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu." : "Keine Treffer für deine Suche." } /> diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index 8b16d2b..1608d33 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -7,6 +7,7 @@ const NAV_ITEMS = [ { to: "/admin/dashboard", label: "Dashboard", icon: "📊" }, { to: "/admin/devices", label: "Geräte", icon: "🖥️" }, { to: "/admin/services", label: "Dienste", icon: "🔗" }, + { to: "/admin/bookmarks", label: "Lesezeichen", 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/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx new file mode 100644 index 0000000..3ebcc0d --- /dev/null +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -0,0 +1,460 @@ +import { useState, type DragEvent, type FormEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Favicon } from "@launchpad/ui"; +import type { Bookmark } from "@launchpad/shared"; +import { useBookmarks } from "../../hooks/useBookmarks.js"; +import { useCategories } from "../../hooks/useCategories.js"; +import { AdminPageHeader } from "./AdminPageHeader.js"; + +interface BookmarkPatch { + url?: string; + displayName?: string; + description?: string | null; + category?: string | null; + favorite?: boolean; + alias?: string[]; + order?: number; +} + +async function createBookmarkRequest(input: { + url: string; + category?: string; + description?: string; +}): Promise { + const res = await fetch("/api/bookmarks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `Lesezeichen konnte nicht angelegt werden (HTTP ${res.status})`); + } + return res.json(); +} + +async function patchBookmark(id: string, patch: BookmarkPatch): Promise { + const res = await fetch(`/api/bookmarks/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + throw new Error(`Lesezeichen konnte nicht aktualisiert werden (HTTP ${res.status})`); + } + return res.json(); +} + +async function deleteBookmarkRequest(id: string) { + const res = await fetch(`/api/bookmarks/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 404) { + throw new Error(`Lesezeichen konnte nicht gelöscht werden (HTTP ${res.status})`); + } +} + +async function reorderBookmarksRequest(entries: { id: string; order: number }[]) { + const res = await fetch("/api/bookmarks/reorder", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(entries), + }); + if (!res.ok) { + throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`); + } + return res.json(); +} + +const NEW_CATEGORY_VALUE = "__new__"; + +function CategorySelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { data: categories } = useCategories(); + const isKnown = !value || categories?.some((c) => c.name === value); + const [isNew, setIsNew] = useState(!isKnown); + + return ( +
+ + {isNew ? ( + onChange(e.target.value)} + placeholder="Name der neuen Kategorie" + 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" + /> + ) : null} +
+ ); +} + +function AddBookmarkForm() { + const queryClient = useQueryClient(); + const [url, setUrl] = useState(""); + const [category, setCategory] = useState(""); + const [description, setDescription] = useState(""); + + const mutation = useMutation({ + mutationFn: () => + createBookmarkRequest({ + url: url.trim(), + category: category.trim() || undefined, + description: description.trim() || undefined, + }), + onSuccess: () => { + setUrl(""); + setCategory(""); + setDescription(""); + queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); + queryClient.invalidateQueries({ queryKey: ["categories"] }); + }, + }); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!url.trim()) return; + mutation.mutate(); + } + + return ( +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com" + className="w-64 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm + text-black outline-none focus:border-black/30 dark:border-white/10 + dark:bg-white/5 dark:text-white dark:focus:border-white/30" + /> +
+
+ + +
+
+ + setDescription(e.target.value)} + placeholder="optional" + className="w-48 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm + text-black outline-none focus:border-black/30 dark:border-white/10 + dark:bg-white/5 dark:text-white dark:focus:border-white/30" + /> +
+ + {mutation.isError ? ( + {(mutation.error as Error).message} + ) : null} + + Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar. + +
+ ); +} + +const EDIT_FORM_COLSPAN = 7; + +function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) { + const queryClient = useQueryClient(); + const [displayName, setDisplayName] = useState(bookmark.displayName); + const [url, setUrl] = useState(bookmark.url); + const [category, setCategory] = useState(bookmark.category ?? ""); + const [description, setDescription] = useState(bookmark.description ?? ""); + const [alias, setAlias] = useState(bookmark.alias.join(", ")); + + const mutation = useMutation({ + mutationFn: () => + patchBookmark(bookmark.id, { + displayName: displayName.trim(), + url: url.trim(), + category: category.trim() || null, + description: description.trim() || null, + alias: alias + .split(",") + .map((a) => a.trim()) + .filter(Boolean), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); + queryClient.invalidateQueries({ queryKey: ["categories"] }); + onDone(); + }, + }); + + return ( + + +
+
+ + 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" + /> +
+
+ + setUrl(e.target.value)} + className="w-56 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" + /> +
+
+ + +
+
+ + setDescription(e.target.value)} + className="w-48 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" + /> +
+
+ + setAlias(e.target.value)} + className="w-40 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" + /> +
+
+ + +
+
+ + + ); +} + +function BookmarkRow({ + bookmark, + onDragStart, + onDragOver, + onDrop, + isDragging, +}: { + bookmark: Bookmark; + onDragStart: (e: DragEvent) => void; + onDragOver: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + isDragging: boolean; +}) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + + const favoriteMutation = useMutation({ + mutationFn: () => patchBookmark(bookmark.id, { favorite: !bookmark.favorite }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteBookmarkRequest(bookmark.id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), + }); + + if (editing) { + return setEditing(false)} />; + } + + return ( + + + + ⠿⠿ + + + + + + +
+ +
+
{bookmark.displayName}
+
{bookmark.hostname}
+
+
+ + {bookmark.category ?? "–"} + + {bookmark.description ?? "–"} + + + + öffnen + + + +
+ + +
+ + + ); +} + +export function BookmarksPage() { + const { data: bookmarks, isLoading, isError } = useBookmarks(); + const queryClient = useQueryClient(); + const [draggedId, setDraggedId] = useState(null); + const [localOrder, setLocalOrder] = useState(null); + + const reorderMutation = useMutation({ + mutationFn: reorderBookmarksRequest, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); + setLocalOrder(null); + }, + onError: () => setLocalOrder(null), + }); + + const list = localOrder ?? bookmarks ?? []; + + function handleDragStart(id: string) { + return (_e: DragEvent) => setDraggedId(id); + } + + function handleDragOver(targetId: string) { + return (e: DragEvent) => { + e.preventDefault(); + if (!draggedId || draggedId === targetId) return; + const current = localOrder ?? bookmarks ?? []; + const fromIndex = current.findIndex((b) => b.id === draggedId); + const toIndex = current.findIndex((b) => b.id === targetId); + if (fromIndex === -1 || toIndex === -1) return; + const next = [...current]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + setLocalOrder(next); + }; + } + + function handleDrop() { + return (e: DragEvent) => { + e.preventDefault(); + setDraggedId(null); + const current = localOrder ?? bookmarks ?? []; + reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index }))); + }; + } + + return ( +
+ + + + + {isLoading ? ( +

Lade Lesezeichen …

+ ) : isError ? ( +

Lesezeichen konnten nicht geladen werden.

+ ) : list.length > 0 ? ( +
+
+ + + + + + + + + + + {list.map((bookmark) => ( + + ))} + +
+ + LesezeichenKategorieBeschreibungURL +
+
+
+ ) : ( +

+ Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu. +

+ )} +
+ ); +} diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 3aee062..c2bd919 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -26,6 +26,7 @@ async function deleteDevice(id: string) { interface ScanResult { scannedPorts: number; + ports: number[]; created: number; updated: number; } @@ -46,8 +47,9 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { const scanMutation = useMutation({ mutationFn: () => scanDevice(device.id), onSuccess: (result) => { + const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine"; setScanMessage( - `${result.scannedPorts} Port(s) offen · ${result.created} neu · ${result.updated} aktualisiert` + `Ports offen: ${portsText} · ${result.created} neu · ${result.updated} aktualisiert` ); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["services"] }); @@ -85,7 +87,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
{scanMessage ? ( - + {scanMessage} ) : null} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index db33b86..062142d 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -1,8 +1,9 @@ -import { useState, type DragEvent } from "react"; +import { useMemo, useRef, useState, type DragEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; import { useServices } from "../../hooks/useServices.js"; +import { useCategories } from "../../hooks/useCategories.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; interface ServicePatch { @@ -49,6 +50,57 @@ async function reorderServicesRequest(entries: { id: string; order: number }[]) return res.json(); } +const NEW_CATEGORY_VALUE = "__new__"; + +function CategorySelect({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const { data: categories } = useCategories(); + const isKnown = !value || categories?.some((c) => c.name === value); + const [isNew, setIsNew] = useState(!isKnown); + + return ( +
+ + {isNew ? ( + onChange(e.target.value)} + placeholder="Name der neuen Kategorie" + autoFocus + 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" + /> + ) : null} +
+ ); +} + const EDIT_FORM_COLSPAN = 9; function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { @@ -81,6 +133,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["services"] }); + queryClient.invalidateQueries({ queryKey: ["categories"] }); onDone(); }, }); @@ -100,12 +153,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
- setCategory(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" - /> +
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cbffc6b..7a5a3d3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -232,3 +232,43 @@ komplett offline funktionierende Variante ersetzt. > Test mit echtem nginx: `/ca.crt` per HTTP heruntergeladen und **damit ohne > `-k`-Flag** eine echte HTTPS-Verbindung erfolgreich validiert – simuliert > exakt das Verhalten eines Geräts nach CA-Import. + +## Dritte Feature-Runde: Lesezeichen, Import/Export, Favoriten im Frontend + +- **Lesezeichen als eigene Entität** (neue Tabelle, eigenes Repository, + eigene Routen unter `/api/bookmarks`) – nicht an ein Gerät gebunden, im + Unterschied zu Diensten. Titel + Favicon werden beim Anlegen automatisch + geladen (Wiederverwendung der Scanner-HTTP-Logik). Erscheinen zusammen mit + Diensten in der Suche (generisches `rankServices` in + `packages/shared`), aber als eigene Favoriten-Gruppe auf der Startseite. +- **Import/Export für Dienste**: CSV, XLSX (echtes Excel, via `xlsx`-Paket) + und JSON, beide Richtungen. Import legt ausschließlich neue Dienste an + (Abgleich über Gerät+Port), bestehende werden nie überschrieben oder + verdoppelt; fehlende Geräte werden bei Bedarf automatisch angelegt. +- **Scan-Ergebnis zeigt jetzt die tatsächlichen Portnummern**, nicht nur + deren Anzahl (`ports: number[]` in der API-Antwort, Anzeige in der + Geräte-Tabelle). +- **Favoriten direkt im Frontend per Drag & Drop sortierbar** (nicht mehr + nur im Adminbereich), getrennte Gruppen für Dienste und Lesezeichen. +- **Favicon-Kontrast-Fix**: neue `Favicon`-Komponente mit immer hellem + Hintergrund, damit dunkle/schwarze Favicons nicht mit dem Dark-Mode- + Hintergrund verschmelzen. +- **Kategorie-Dropdown** im Bearbeiten-Formular (Dienste + Lesezeichen) + mit bestehenden Kategorien plus "+ Neue Kategorie …"-Option, statt freiem + Textfeld. +- **Sortierbare Spaltenköpfe** in der Dienste-Tabelle (Name, Kategorie, + Alias, Port, Protokoll). Bei aktiver Spaltensortierung ist Drag & Drop + vorübergehend deaktiviert (macht in dem Moment keinen Sinn), ein Klick auf + "zurück zur manuellen Reihenfolge" stellt den Drag-&-Drop-Modus wieder her. + +> Verifiziert (Backend, alles per echtem HTTP-Roundtrip getestet): Lesezeichen +> anlegen mit automatischem Titel-/Favicon-Abruf gegen einen echten +> Testserver, automatisches Anlegen der Kategorie; CSV-, XLSX- und +> JSON-Export erzeugt (XLSX als `file`-Befehl gegengeprüft: "Microsoft Excel +> 2007+"); Import in allen drei Formaten getestet, insbesondere der +> kritische Fall "bestehender Dienst bleibt unverändert, neuer wird +> angelegt, keine Duplikate"; Bookmark- und Service-Reorder-Endpunkte +> getestet. Frontend: vollständiger `pnpm build` (inkl. `tsc --noEmit`) +> erfolgreich – die eigentliche UI-Interaktion (Drag & Drop, Dropdown- +> Verhalten) konnte mangels Browser in dieser Umgebung nicht geklickt +> werden, nur durch Code-Review abgesichert. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c3b1691..40fe57f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -73,6 +73,44 @@ export interface PluginInfo { capabilities: string[]; } +/** + * Ein manuell angelegtes Lesezeichen – im Unterschied zu Diensten nicht an + * ein gescanntes Gerät gebunden. Erscheint zusammen mit Diensten in der + * Suche, wird aber als eigene Entität verwaltet (siehe Admin -> Lesezeichen). + */ +export interface Bookmark { + id: string; + url: string; + displayName: string; + hostname: string; // aus der URL abgeleitet, für konsistentes Ranking + description: string | null; + category: string | null; + icon: string | null; + favicon: string | null; + favorite: boolean; + alias: string[]; + order: number; +} + +/** Gemeinsame Form, die Service und Bookmark fürs Ranking erfüllen. */ +export interface Rankable { + displayName: string; + hostname: string; + alias: string[]; + description: string | null; + favorite: boolean; + order: number; +} + +/** + * Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste und + * Lesezeichen zusammen, aber unterscheidbar über `kind` (siehe + * "getrennt von Diensten" bei Favoriten). + */ +export type SearchResult = + | (Service & { kind: "service" }) + | (Bookmark & { kind: "bookmark" }); + /** * Ranking-Stufen für die Suche, gemäß Spezifikation: * 1. Displayname beginnt mit Suchtext @@ -83,15 +121,17 @@ export interface PluginInfo { * 6. Beschreibung enthält Suchtext * * Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer. + * Funktioniert generisch für alles, was die Rankable-Form erfüllt + * (Service, Bookmark). */ -export function rankService(service: Service, query: string): number | null { +export function rankService(item: T, query: string): number | null { const q = query.trim().toLowerCase(); if (q.length === 0) return null; - const displayName = service.displayName.toLowerCase(); - const hostname = service.hostname.toLowerCase(); - const description = (service.description ?? "").toLowerCase(); - const alias = service.alias.map((a) => a.toLowerCase()); + const displayName = item.displayName.toLowerCase(); + const hostname = item.hostname.toLowerCase(); + const description = (item.description ?? "").toLowerCase(); + const alias = item.alias.map((a) => a.toLowerCase()); if (displayName.startsWith(q)) return 1; if (alias.some((a) => a.startsWith(q))) return 2; @@ -104,26 +144,27 @@ export function rankService(service: Service, query: string): number | null { } /** - * Sortiert und filtert eine Liste von Diensten anhand des Suchtexts. - * Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge. + * Sortiert und filtert eine Liste (Dienste, Lesezeichen oder eine Mischung + * über einen gemeinsamen Union-Typ) anhand des Suchtexts. Favoriten werden + * bei gleichem Rang bevorzugt, danach die definierte Reihenfolge. */ -export function rankServices(services: Service[], query: string): Service[] { +export function rankServices(items: T[], query: string): T[] { const q = query.trim(); if (q.length === 0) { - return [...services].sort((a, b) => { + return [...items].sort((a, b) => { if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; return a.order - b.order; }); } - return services - .map((service) => ({ service, rank: rankService(service, q) })) - .filter((entry): entry is { service: Service; rank: number } => entry.rank !== null) + return items + .map((item) => ({ item, rank: rankService(item, q) })) + .filter((entry): entry is { item: T; rank: number } => entry.rank !== null) .sort((a, b) => { if (a.rank !== b.rank) return a.rank - b.rank; - if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1; - return a.service.order - b.service.order; + if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1; + return a.item.order - b.item.order; }) - .map((entry) => entry.service); + .map((entry) => entry.item); } diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 0442df5..4b84714 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -78,3 +78,31 @@ export const CategoryReorderSchema = z ) .min(1, "mindestens ein Eintrag erforderlich"); export type CategoryReorderInput = z.infer; + +export const BookmarkCreateSchema = z.object({ + url: z.string().url("url muss eine gültige URL sein"), + // Optional: wird nicht angegeben, versucht das Backend automatisch den + // Seitentitel zu lesen (siehe apps/backend/src/routes/bookmarks.ts). + displayName: z.string().min(1).optional(), + description: z.string().optional(), + category: z.string().optional(), + icon: z.string().optional(), + favorite: z.boolean().optional(), + alias: z.array(z.string()).optional(), + order: z.number().optional(), +}); +export type BookmarkCreateInput = z.infer; + +export const BookmarkUpdateSchema = BookmarkCreateSchema.partial(); +export type BookmarkUpdateInput = z.infer; + +/** Für Drag & Drop: neue Reihenfolge mehrerer Lesezeichen auf einmal setzen. */ +export const BookmarkReorderSchema = z + .array( + z.object({ + id: z.string().min(1), + order: z.number(), + }) + ) + .min(1, "mindestens ein Eintrag erforderlich"); +export type BookmarkReorderInput = z.infer; diff --git a/packages/ui/src/Favicon.tsx b/packages/ui/src/Favicon.tsx new file mode 100644 index 0000000..ac9e7a9 --- /dev/null +++ b/packages/ui/src/Favicon.tsx @@ -0,0 +1,41 @@ +export interface FaviconProps { + src?: string | null; + fallbackLetter: string; + size?: "sm" | "md"; +} + +const SIZE_CLASSES: Record, string> = { + sm: "h-4 w-4", + md: "h-5 w-5", +}; + +/** + * Zeigt ein Favicon mit einem immer hellen Hintergrund (unabhängig vom + * Dark/Light-Theme der App) – viele Favicons sind selbst dunkel/schwarz und + * wären auf dunklem Hintergrund sonst kaum zu erkennen. Ohne Favicon wird + * stattdessen der erste Buchstabe des Namens gezeigt. + */ +export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) { + const dimension = SIZE_CLASSES[size]; + + if (!src) { + return ( + + {fallbackLetter.charAt(0).toUpperCase()} + + ); + } + + return ( + + + + ); +} diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx index 2589c8c..26cf4ff 100644 --- a/packages/ui/src/FavoritesBar.tsx +++ b/packages/ui/src/FavoritesBar.tsx @@ -1,47 +1,101 @@ -import type { Service } from "@launchpad/shared"; +import { useState, type DragEvent } from "react"; +import { Favicon } from "./Favicon.js"; + +export interface FavoriteItem { + id: string; + displayName: string; + favicon: string | null; + hostname: string; + port?: number; +} export interface FavoritesBarProps { - services: Service[]; - onOpen: (service: Service) => void; + items: FavoriteItem[]; + label?: string; + onOpen: (item: FavoriteItem) => void; + /** Wenn gesetzt, sind die Chips per Drag & Drop sortierbar. */ + onReorder?: (orderedIds: string[]) => void; } /** - * Zeigt Favoriten als anklickbare Chips – immer sichtbar, unabhängig vom - * Suchfeld. Reihenfolge folgt service.order (im Adminbereich per Drag & Drop - * änderbar). + * Zeigt Favoriten als anklickbare, per Drag & Drop sortierbare Chips – immer + * sichtbar, unabhängig vom Suchfeld. Dienste und Lesezeichen werden über + * getrennte FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein + * generisch über FavoriteItem statt fest an Service gebunden. */ -export function FavoritesBar({ services, onOpen }: FavoritesBarProps) { - if (services.length === 0) return null; +export function FavoritesBar({ items, label, onOpen, onReorder }: FavoritesBarProps) { + const [draggedId, setDraggedId] = useState(null); + const [localOrder, setLocalOrder] = useState(null); + + if (items.length === 0) return null; + + const list = localOrder ?? items; + + function handleDragStart(id: string) { + return (_e: DragEvent) => setDraggedId(id); + } + + function handleDragOver(targetId: string) { + return (e: DragEvent) => { + e.preventDefault(); + if (!draggedId || draggedId === targetId) return; + + const current = localOrder ?? items; + const fromIndex = current.findIndex((i) => i.id === draggedId); + const toIndex = current.findIndex((i) => i.id === targetId); + if (fromIndex === -1 || toIndex === -1) return; + + const next = [...current]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + setLocalOrder(next); + }; + } + + function handleDrop(e: DragEvent) { + e.preventDefault(); + setDraggedId(null); + const current = localOrder ?? items; + onReorder?.(current.map((i) => i.id)); + } return ( -
- {services.map((service) => ( - - ))} +
+ {label ? ( +
+ {label} +
+ ) : null} +
+ {list.map((item) => ( + + ))} +
); } diff --git a/packages/ui/src/ResultsList.tsx b/packages/ui/src/ResultsList.tsx index cd7b019..1aa93cd 100644 --- a/packages/ui/src/ResultsList.tsx +++ b/packages/ui/src/ResultsList.tsx @@ -1,29 +1,31 @@ import type { KeyboardEvent } from "react"; -import type { Service } from "@launchpad/shared"; +import type { SearchResult } from "@launchpad/shared"; +import { Favicon } from "./Favicon.js"; export interface ResultsListProps { - services: Service[]; + results: SearchResult[]; selectedIndex: number; emptyLabel?: string; onHover: (index: number) => void; - onOpen: (service: Service) => void; - onToggleFavorite?: (service: Service) => void; + onOpen: (item: SearchResult) => void; + onToggleFavorite?: (item: SearchResult) => void; } /** - * Zeigt die (bereits per rankServices sortierten) Suchtreffer an. - * Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement - * gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar. + * Zeigt die (bereits per rankServices sortierten) Suchtreffer an – Dienste + * und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die + * Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert; + * diese Komponente ist rein darstellend + klick-/tastaturbar. */ export function ResultsList({ - services, + results, selectedIndex, - emptyLabel = "Keine Dienste gefunden.", + emptyLabel = "Keine Treffer gefunden.", onHover, onOpen, onToggleFavorite, }: ResultsListProps) { - if (services.length === 0) { + if (results.length === 0) { return (
- {services.map((service, index) => { + {results.map((item, index) => { const active = index === selectedIndex; + const subtitle = + item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname; + return ( -
  • +
  • onHover(index)} - onClick={() => onOpen(service)} + onClick={() => onOpen(item)} onKeyDown={(e: KeyboardEvent) => { - if (e.key === "Enter") onOpen(service); + if (e.key === "Enter") onOpen(item); }} className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left transition-colors ${ @@ -59,30 +64,27 @@ export function ResultsList({ : "hover:bg-black/[0.03] dark:hover:bg-white/5" }`} > - {service.favicon ? ( - - ) : ( - - {service.displayName.charAt(0).toUpperCase()} - - )} + - - {service.displayName} + + + {item.displayName} + + {item.kind === "bookmark" ? ( + + Lesezeichen + + ) : null} - {service.hostname}:{service.port} + {subtitle} - {service.category ? ( + {item.category ? ( - {service.category} + {item.category} ) : null} @@ -90,15 +92,13 @@ export function ResultsList({ type="button" onClick={(e) => { e.stopPropagation(); - onToggleFavorite?.(service); + onToggleFavorite?.(item); }} disabled={!onToggleFavorite} - aria-pressed={service.favorite} - aria-label={ - service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren" - } + aria-pressed={item.favorite} + aria-label={item.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"} className={`shrink-0 text-lg leading-none transition-colors ${ - service.favorite + item.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400" } ${onToggleFavorite ? "" : "cursor-default"}`} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0dfe61e..972db44 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,3 +12,6 @@ export type { ButtonProps } from "./Button.js"; export { FavoritesBar } from "./FavoritesBar.js"; export type { FavoritesBarProps } from "./FavoritesBar.js"; + +export { Favicon } from "./Favicon.js"; +export type { FaviconProps } from "./Favicon.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40fa12e..fd28c99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: fastify: specifier: ^4.28.1 version: 4.29.1 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@types/better-sqlite3': specifier: ^7.6.11 @@ -84,10 +87,10 @@ importers: version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.49.0)) autoprefixer: specifier: ^10.4.20 - version: 10.5.4(postcss@8.5.19) + version: 10.5.4(postcss@8.5.20) postcss: specifier: ^8.4.41 - version: 8.5.19 + version: 8.5.20 tailwindcss: specifier: ^3.4.10 version: 3.4.19(tsx@4.23.1) @@ -1549,6 +1552,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1702,6 +1709,10 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1709,6 +1720,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1736,6 +1751,11 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2026,11 +2046,11 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@2.4.2: - resolution: {integrity: sha512-Ll1wlF3LBJ2+vFEeTSH9SFrjnXJorZQexrn0yHa4BJdGS+FFkWW3xU/YuIdmdyloSDuUgTYh/YeY/vUNcdCS/g==} + fast-uri@2.4.3: + resolution: {integrity: sha512-8V8UrSDUkYpi4AXM7Na0G6hctXSaRHBGMuANOotuFdHEFtTdqDTRNfcDczA9WkKODI17o7o10iQvzdMIxXb8eA==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} @@ -2076,6 +2096,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2602,8 +2626,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -2867,6 +2891,10 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3120,6 +3148,14 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + workbox-background-sync@7.4.1: resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} @@ -3172,6 +3208,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4159,7 +4200,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 2.1.1(ajv@8.20.0) - fast-uri: 2.4.2 + fast-uri: 2.4.3 '@fastify/cors@9.0.1': dependencies: @@ -4444,6 +4485,8 @@ snapshots: acorn@8.17.0: {} + adler-32@1.3.1: {} + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4455,7 +4498,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4491,13 +4534,13 @@ snapshots: atomic-sleep@1.0.0: {} - autoprefixer@10.5.4(postcss@8.5.19): + autoprefixer@10.5.4(postcss@8.5.20): dependencies: browserslist: 4.28.6 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.19 + postcss: 8.5.20 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -4611,6 +4654,11 @@ snapshots: caniuse-lite@1.0.30001806: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4625,6 +4673,8 @@ snapshots: chownr@1.1.4: {} + codepage@1.15.0: {} + colorette@2.0.20: {} commander@2.20.3: {} @@ -4643,6 +4693,8 @@ snapshots: dependencies: browserslist: 4.28.6 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4978,7 +5030,7 @@ snapshots: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) fast-deep-equal: 3.1.3 - fast-uri: 2.4.2 + fast-uri: 2.4.3 json-schema-ref-resolver: 1.0.1 rfdc: 1.4.1 @@ -4988,9 +5040,9 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@2.4.2: {} + fast-uri@2.4.3: {} - fast-uri@3.1.3: {} + fast-uri@3.1.4: {} fastify-plugin@4.5.1: {} @@ -5048,6 +5100,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} fs-constants@1.0.0: {} @@ -5512,29 +5566,29 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.19): + postcss-import@15.1.0(postcss@8.5.20): dependencies: - postcss: 8.5.19 + postcss: 8.5.20 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.19): + postcss-js@4.1.0(postcss@8.5.20): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.19 + postcss: 8.5.20 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.23.1): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.20)(tsx@4.23.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.19 + postcss: 8.5.20 tsx: 4.23.1 - postcss-nested@6.2.0(postcss@8.5.19): + postcss-nested@6.2.0(postcss@8.5.20): dependencies: - postcss: 8.5.19 + postcss: 8.5.20 postcss-selector-parser: 6.1.4 postcss-selector-parser@6.1.4: @@ -5544,7 +5598,7 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.19: + postcss@8.5.20: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 @@ -5861,6 +5915,10 @@ snapshots: split2@4.2.0: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5950,11 +6008,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.19 - postcss-import: 15.1.0(postcss@8.5.19) - postcss-js: 4.1.0(postcss@8.5.19) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.23.1) - postcss-nested: 6.2.0(postcss@8.5.19) + postcss: 8.5.20 + postcss-import: 15.1.0(postcss@8.5.20) + postcss-js: 4.1.0(postcss@8.5.20) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.20)(tsx@4.23.1) + postcss-nested: 6.2.0(postcss@8.5.20) postcss-selector-parser: 6.1.4 resolve: 1.22.12 sucrase: 3.35.1 @@ -6123,7 +6181,7 @@ snapshots: vite@5.4.21(@types/node@20.19.43)(terser@5.49.0): dependencies: esbuild: 0.21.5 - postcss: 8.5.19 + postcss: 8.5.20 rollup: 4.62.2 optionalDependencies: '@types/node': 20.19.43 @@ -6183,6 +6241,10 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + + word@0.3.0: {} + workbox-background-sync@7.4.1: dependencies: idb: 7.1.1 @@ -6298,6 +6360,16 @@ snapshots: wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} zod@3.25.76: {}