Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown

This commit is contained in:
2026-07-19 21:56:25 +02:00
parent 31e17ff77b
commit dc91a9aba9
24 changed files with 1655 additions and 180 deletions

View File

@@ -199,6 +199,17 @@ POST /api/services erfordert existierende deviceId
PATCH /api/services/reorder Body: [{ id, order }, ...] PATCH /api/services/reorder Body: [{ id, order }, ...]
PATCH /api/services/:id PATCH /api/services/:id
DELETE /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 GET /api/categories
POST /api/categories POST /api/categories
@@ -229,6 +240,7 @@ POST /api/plugins/:name/import löst importDevices() eines Plugins aus
/admin/dashboard /admin/dashboard
/admin/devices /admin/devices
/admin/services /admin/services
/admin/bookmarks
/admin/categories /admin/categories
/admin/scanner /admin/scanner
/admin/plugins Hinweis: Plugin-System noch nicht gebaut /admin/plugins Hinweis: Plugin-System noch nicht gebaut

View File

@@ -17,7 +17,8 @@
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"fastify": "^4.28.1" "fastify": "^4.28.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",

View File

@@ -76,6 +76,22 @@ export function ensureSchema(): void {
message TEXT NOT NULL, message TEXT NOT NULL,
created_at 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" // Leichte Migration für Datenbanken, die vor Einführung von "visible"

View File

@@ -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();
}

View File

@@ -75,3 +75,25 @@ export const scanLogs = sqliteTable("scan_logs", {
message: text("message").notNull(), message: text("message").notNull(),
createdAt: text("created_at").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(),
});

View File

@@ -10,8 +10,10 @@ import { scanRoutes } from "./routes/scan.js";
import { logRoutes } from "./routes/logs.js"; import { logRoutes } from "./routes/logs.js";
import { pluginRoutes } from "./routes/plugins.js"; import { pluginRoutes } from "./routes/plugins.js";
import { resetRoutes } from "./routes/reset.js"; import { resetRoutes } from "./routes/reset.js";
import { bookmarkRoutes } from "./routes/bookmarks.js";
import { loadPlugins } from "./plugins/loader.js"; import { loadPlugins } from "./plugins/loader.js";
import * as serviceRepo from "./db/repositories/services.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"; import * as categoryRepo from "./db/repositories/categories.js";
const PORT = Number(process.env.PORT ?? 3001); const PORT = Number(process.env.PORT ?? 3001);
@@ -40,10 +42,10 @@ async function main() {
// der Suche als Kategorie auftaucht. // der Suche als Kategorie auftaucht.
const existingCategoryNames = Array.from( const existingCategoryNames = Array.from(
new Set( new Set(
serviceRepo [
.listServices() ...serviceRepo.listServices().map((s) => s.category),
.map((s) => s.category) ...bookmarkRepo.listBookmarks().map((b) => b.category),
.filter((c): c is string => !!c) ].filter((c): c is string => !!c)
) )
); );
const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames); const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames);
@@ -62,6 +64,7 @@ async function main() {
await app.register(logRoutes); await app.register(logRoutes);
await app.register(pluginRoutes); await app.register(pluginRoutes);
await app.register(resetRoutes); await app.register(resetRoutes);
await app.register(bookmarkRoutes);
app.get("/", async () => { app.get("/", async () => {
return { name: "LaunchPad API", status: "running" }; return { name: "LaunchPad API", status: "running" };

View File

@@ -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<void> {
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();
});
}

View File

@@ -58,17 +58,19 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
const created = results.filter((r) => r.created).length; const created = results.filter((r) => r.created).length;
const updated = 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({ logRepo.logScan({
type: "device", type: "device",
targetId: device.id, targetId: device.id,
level: "info", 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 { return {
deviceId: device.id, deviceId: device.id,
scannedPorts: discovered.length, scannedPorts: discovered.length,
ports,
created, created,
updated, updated,
services: results.map((r) => r.service), services: results.map((r) => r.service),

View File

@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import * as XLSX from "xlsx";
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import * as categoryRepo from "../db/repositories/categories.js";
interface ServiceListQuery { interface ServiceListQuery {
deviceId?: string; deviceId?: string;
@@ -9,6 +11,45 @@ interface ServiceListQuery {
favorite?: string; 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<void> { export async function serviceRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/services", async (request) => { app.get("/api/services", async (request) => {
const query = request.query as ServiceListQuery; const query = request.query as ServiceListQuery;
@@ -43,6 +84,136 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(service); 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<string, unknown>[];
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. // Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
app.patch("/api/services/reorder", async (request, reply) => { app.patch("/api/services/reorder", async (request, reply) => {
const parsed = ServiceReorderSchema.safeParse(request.body); const parsed = ServiceReorderSchema.safeParse(request.body);

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import type { Bookmark } from "@launchpad/shared";
async function fetchBookmarks(): Promise<Bookmark[]> {
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,
});
}

View File

@@ -4,6 +4,7 @@ import { AdminLayout } from "./routes/admin/AdminLayout.js";
import { DashboardPage } from "./routes/admin/DashboardPage.js"; import { DashboardPage } from "./routes/admin/DashboardPage.js";
import { DevicesPage } from "./routes/admin/DevicesPage.js"; import { DevicesPage } from "./routes/admin/DevicesPage.js";
import { ServicesPage } from "./routes/admin/ServicesPage.js"; import { ServicesPage } from "./routes/admin/ServicesPage.js";
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
import { CategoriesPage } from "./routes/admin/CategoriesPage.js"; import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
import { ScannerPage } from "./routes/admin/ScannerPage.js"; import { ScannerPage } from "./routes/admin/ScannerPage.js";
import { PluginsPage } from "./routes/admin/PluginsPage.js"; import { PluginsPage } from "./routes/admin/PluginsPage.js";
@@ -53,6 +54,12 @@ const adminServicesRoute = createRoute({
component: ServicesPage, component: ServicesPage,
}); });
const adminBookmarksRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/bookmarks",
component: BookmarksPage,
});
const adminCategoriesRoute = createRoute({ const adminCategoriesRoute = createRoute({
getParentRoute: () => adminRoute, getParentRoute: () => adminRoute,
path: "/categories", path: "/categories",
@@ -90,6 +97,7 @@ const routeTree = rootRoute.addChildren([
adminDashboardRoute, adminDashboardRoute,
adminDevicesRoute, adminDevicesRoute,
adminServicesRoute, adminServicesRoute,
adminBookmarksRoute,
adminCategoriesRoute, adminCategoriesRoute,
adminScannerRoute, adminScannerRoute,
adminPluginsRoute, adminPluginsRoute,

View File

@@ -2,25 +2,38 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui"; 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 { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.js"; import { useTheme } from "../hooks/useTheme.js";
function openService(service: Service) { function openItem(item: SearchResult) {
window.open(service.url, "_blank", "noopener,noreferrer"); window.open(item.url, "_blank", "noopener,noreferrer");
} }
async function toggleServiceFavorite(service: Service): Promise<Service> { async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
const res = await fetch(`/api/services/${service.id}`, { const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`;
const res = await fetch(path, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: !service.favorite }), body: JSON.stringify({ favorite: !item.favorite }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`); throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
} }
return res.json(); }
async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise<void> {
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() { export function HomePage() {
@@ -28,36 +41,56 @@ export function HomePage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const { health, error: healthError } = useBackendHealth(); 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<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isSearching = query.trim().length > 0; const isSearching = query.trim().length > 0;
const isLoading = servicesLoading || bookmarksLoading;
const toggleFavorite = useMutation({ const toggleFavorite = useMutation({
mutationFn: toggleServiceFavorite, mutationFn: toggleFavoriteRequest,
onSuccess: () => { onSuccess: (_data, item) => {
queryClient.invalidateQueries({ queryKey: ["services"] }); 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, // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
// siehe Admin -> Dienste) tauchen in der Suche nicht auf. // siehe Admin -> Dienste) tauchen in der Suche nicht auf.
const visibleServices = useMemo( const allItems: SearchResult[] = useMemo(() => {
() => (services ?? []).filter((s) => s.visible), const visibleServices: SearchResult[] = (services ?? [])
[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( const results = useMemo(() => rankServices(allItems, query), [allItems, query]);
() => rankServices(visibleServices, query),
[visibleServices, query]
);
const favoriteServices = useMemo( const favoriteServices = useMemo(
() => () =>
visibleServices (services ?? [])
.filter((s) => s.favorite) .filter((s) => s.visible && s.favorite)
.sort((a, b) => a.order - b.order), .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 // Auswahl zurücksetzen, sobald sich die Trefferliste ändert
@@ -88,7 +121,7 @@ export function HomePage() {
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const target = results[selectedIndex]; const target = results[selectedIndex];
if (target) openService(target); if (target) openItem(target);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
inputRef.current?.blur(); inputRef.current?.blur();
setQuery(""); setQuery("");
@@ -100,6 +133,7 @@ export function HomePage() {
}, [results, selectedIndex, query]); }, [results, selectedIndex, query]);
const isOnline = !healthError && health?.status === "ok"; const isOnline = !healthError && health?.status === "ok";
const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0;
return ( return (
<div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950"> <div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950">
@@ -132,9 +166,30 @@ export function HomePage() {
</div> </div>
<div className="w-full max-w-xl"> <div className="w-full max-w-xl">
{hasFavorites ? (
<div className="mb-4 flex flex-col gap-3">
{favoriteServices.length > 0 ? ( {favoriteServices.length > 0 ? (
<div className="mb-4"> <FavoritesBar
<FavoritesBar services={favoriteServices} onOpen={openService} /> items={favoriteServices}
label="Dienste"
onOpen={(item) => {
const service = favoriteServices.find((s) => s.id === item.id);
if (service) openItem({ ...service, kind: "service" });
}}
onReorder={(ids) => reorderServices.mutate(ids)}
/>
) : null}
{favoriteBookmarks.length > 0 ? (
<FavoritesBar
items={favoriteBookmarks}
label="Lesezeichen"
onOpen={(item) => {
const bookmark = favoriteBookmarks.find((b) => b.id === item.id);
if (bookmark) openItem({ ...bookmark, kind: "bookmark" });
}}
onReorder={(ids) => reorderBookmarks.mutate(ids)}
/>
) : null}
</div> </div>
) : null} ) : null}
@@ -142,29 +197,27 @@ export function HomePage() {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Dienst suchen … z. B. „frigate“" placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“"
hint="⌘K" hint="⌘K"
autoFocus autoFocus
/> />
{!isSearching ? null : isLoading ? ( {!isSearching ? null : isLoading ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40"> <p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">Lade </p>
Lade Dienste ) : servicesError ? (
</p>
) : isError ? (
<p className="mt-4 text-center text-sm text-red-500"> <p className="mt-4 text-center text-sm text-red-500">
Dienste konnten nicht geladen werden. Dienste konnten nicht geladen werden.
</p> </p>
) : ( ) : (
<ResultsList <ResultsList
services={results} results={results}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onHover={setSelectedIndex} onHover={setSelectedIndex}
onOpen={openService} onOpen={openItem}
onToggleFavorite={(service) => toggleFavorite.mutate(service)} onToggleFavorite={(item) => toggleFavorite.mutate(item)}
emptyLabel={ emptyLabel={
(visibleServices?.length ?? 0) === 0 allItems.length === 0
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu." ? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu."
: "Keine Treffer für deine Suche." : "Keine Treffer für deine Suche."
} }
/> />

View File

@@ -7,6 +7,7 @@ const NAV_ITEMS = [
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" }, { to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" }, { to: "/admin/devices", label: "Geräte", icon: "🖥️" },
{ to: "/admin/services", label: "Dienste", icon: "🔗" }, { to: "/admin/services", label: "Dienste", icon: "🔗" },
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: "🔖" },
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" }, { to: "/admin/scanner", label: "Scanner", icon: "🔍" },
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" }, { to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" }, { to: "/admin/plugins", label: "Plugins", icon: "🧩" },

View File

@@ -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<Bookmark> {
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<Bookmark> {
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 (
<div className="flex flex-col gap-1">
<select
value={isNew ? NEW_CATEGORY_VALUE : value}
onChange={(e) => {
if (e.target.value === NEW_CATEGORY_VALUE) {
setIsNew(true);
onChange("");
} else {
setIsNew(false);
onChange(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"
>
<option value=""> Keine </option>
{categories?.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie </option>
</select>
{isNew ? (
<input
value={value}
onChange={(e) => 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}
</div>
);
}
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 (
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">URL</label>
<input
value={url}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Kategorie
</label>
<CategorySelect value={category} onChange={setCategory} />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Beschreibung
</label>
<input
value={description}
onChange={(e) => 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"
/>
</div>
<Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Lade Titel/Favicon …" : "Anlegen"}
</Button>
{mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null}
<span className="w-full text-xs text-black/40 dark:text-white/40">
Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar.
</span>
</form>
);
}
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 (
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
<CategorySelect value={category} onChange={setCategory} />
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
Beschreibung
</label>
<input
value={description}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
Alias (kommagetrennt)
</label>
<input
value={alias}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Abbrechen
</Button>
</div>
</div>
</td>
</tr>
);
}
function BookmarkRow({
bookmark,
onDragStart,
onDragOver,
onDrop,
isDragging,
}: {
bookmark: Bookmark;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
onDrop: (e: DragEvent<HTMLTableRowElement>) => 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 <EditForm bookmark={bookmark} onDone={() => setEditing(false)} />;
}
return (
<tr
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
>
<td className="px-2 py-3 text-center">
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
</span>
</td>
<td className="px-2 py-3">
<button
onClick={() => favoriteMutation.mutate()}
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${bookmark.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Favicon src={bookmark.favicon} fallbackLetter={bookmark.displayName} size="sm" />
<div>
<div className="font-medium text-black dark:text-white">{bookmark.displayName}</div>
<div className="text-xs text-black/40 dark:text-white/40">{bookmark.hostname}</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{bookmark.category ?? ""}</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">
{bookmark.description ?? ""}
</td>
<td className="px-4 py-3">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
>
öffnen
</a>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Button size="sm" onClick={() => setEditing(true)}>
Bearbeiten
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
</div>
</td>
</tr>
);
}
export function BookmarksPage() {
const { data: bookmarks, isLoading, isError } = useBookmarks();
const queryClient = useQueryClient();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Bookmark[] | null>(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<HTMLTableRowElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLTableRowElement>) => {
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<HTMLTableRowElement>) => {
e.preventDefault();
setDraggedId(null);
const current = localOrder ?? bookmarks ?? [];
reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index })));
};
}
return (
<div>
<AdminPageHeader
title="Lesezeichen"
description="Eigenständig von Diensten erscheinen zusammen mit ihnen in der Suche, aber als eigene Favoriten-Gruppe auf der Startseite. Per Drag & Drop sortierbar."
/>
<AddBookmarkForm />
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Lesezeichen </p>
) : isError ? (
<p className="text-sm text-red-500">Lesezeichen konnten nicht geladen werden.</p>
) : list.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-2 py-2" />
<th className="px-2 py-2" />
<th className="px-4 py-2 font-medium">Lesezeichen</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<th className="px-4 py-2 font-medium">Beschreibung</th>
<th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{list.map((bookmark) => (
<BookmarkRow
key={bookmark.id}
bookmark={bookmark}
isDragging={draggedId === bookmark.id}
onDragStart={handleDragStart(bookmark.id)}
onDragOver={handleDragOver(bookmark.id)}
onDrop={handleDrop()}
/>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu.
</p>
)}
</div>
);
}

View File

@@ -26,6 +26,7 @@ async function deleteDevice(id: string) {
interface ScanResult { interface ScanResult {
scannedPorts: number; scannedPorts: number;
ports: number[];
created: number; created: number;
updated: number; updated: number;
} }
@@ -46,8 +47,9 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => scanDevice(device.id), mutationFn: () => scanDevice(device.id),
onSuccess: (result) => { onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
setScanMessage( 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: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
@@ -85,7 +87,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{scanMessage ? ( {scanMessage ? (
<span className="max-w-[16rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}> <span className="max-w-[22rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}>
{scanMessage} {scanMessage}
</span> </span>
) : null} ) : null}

View File

@@ -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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; import type { Service } from "@launchpad/shared";
import { useServices } from "../../hooks/useServices.js"; import { useServices } from "../../hooks/useServices.js";
import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
interface ServicePatch { interface ServicePatch {
@@ -49,6 +50,57 @@ async function reorderServicesRequest(entries: { id: string; order: number }[])
return res.json(); 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 (
<div className="flex flex-col gap-1">
<select
value={isNew ? NEW_CATEGORY_VALUE : value}
onChange={(e) => {
if (e.target.value === NEW_CATEGORY_VALUE) {
setIsNew(true);
onChange("");
} else {
setIsNew(false);
onChange(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"
>
<option value=""> Keine </option>
{categories?.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie </option>
</select>
{isNew ? (
<input
value={value}
onChange={(e) => 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}
</div>
);
}
const EDIT_FORM_COLSPAN = 9; const EDIT_FORM_COLSPAN = 9;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
@@ -81,6 +133,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
onDone(); onDone();
}, },
}); });
@@ -100,12 +153,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
<input <CategorySelect value={category} onChange={setCategory} />
value={category}
onChange={(e) => 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"
/>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50"> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">
@@ -185,12 +233,14 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
function ServiceRow({ function ServiceRow({
service, service,
draggable,
onDragStart, onDragStart,
onDragOver, onDragOver,
onDrop, onDrop,
isDragging, isDragging,
}: { }: {
service: Service; service: Service;
draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void; onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void; onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
onDrop: (e: DragEvent<HTMLTableRowElement>) => void; onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
@@ -220,7 +270,7 @@ function ServiceRow({
return ( return (
<tr <tr
draggable draggable={draggable}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
@@ -229,7 +279,10 @@ function ServiceRow({
} ${isDragging ? "opacity-40" : ""}`} } ${isDragging ? "opacity-40" : ""}`}
> >
<td className="px-2 py-3 text-center"> <td className="px-2 py-3 text-center">
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden> <span
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
aria-hidden
>
</span> </span>
</td> </td>
@@ -303,11 +356,136 @@ function ServiceRow({
); );
} }
type SortColumn = "displayName" | "category" | "alias" | "port" | "https" | null;
function SortableHeader({
label,
column,
activeColumn,
direction,
onClick,
}: {
label: string;
column: SortColumn;
activeColumn: SortColumn;
direction: "asc" | "desc";
onClick: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
return (
<th className="px-4 py-2 font-medium">
<button
onClick={() => onClick(column)}
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
active ? "text-black dark:text-white" : ""
}`}
>
{label}
<span className="text-[10px]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</span>
</button>
</th>
);
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(",")[1] ?? "");
};
reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden"));
reader.readAsDataURL(file);
});
}
function detectFormat(filename: string): "csv" | "xlsx" | "json" {
if (filename.toLowerCase().endsWith(".xlsx")) return "xlsx";
if (filename.toLowerCase().endsWith(".json")) return "json";
return "csv";
}
interface ImportResult {
imported: number;
skipped: number;
errors: string[];
}
function ImportExportBar() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const importMutation = useMutation({
mutationFn: async (file: File): Promise<ImportResult> => {
const content = await readFileAsBase64(file);
const format = detectFormat(file.name);
const res = await fetch("/api/services/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format, content }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`);
}
return res.json();
},
onSuccess: (data) => {
setResult(data);
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
onError: (err: Error) => setResult({ imported: 0, skipped: 0, errors: [err.message] }),
});
return (
<div className="mb-6 flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-black/50 dark:text-white/50">Export:</span>
<a href="/api/services/export?format=csv" download>
<Button size="sm">CSV</Button>
</a>
<a href="/api/services/export?format=xlsx" download>
<Button size="sm">Excel</Button>
</a>
<a href="/api/services/export?format=json" download>
<Button size="sm">JSON</Button>
</a>
<span className="ml-4 text-xs font-medium text-black/50 dark:text-white/50">Import:</span>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => fileInputRef.current?.click()} disabled={importMutation.isPending}>
{importMutation.isPending ? "Importiere …" : "Datei wählen (CSV/Excel/JSON)"}
</Button>
{result ? (
<span className="w-full text-xs text-black/50 dark:text-white/50">
{result.imported} importiert, {result.skipped} übersprungen (bereits vorhanden)
{result.errors.length > 0 ? `, ${result.errors.length} Fehler: ${result.errors.join(" | ")}` : "."}
</span>
) : null}
</div>
);
}
export function ServicesPage() { export function ServicesPage() {
const { data: services, isLoading, isError } = useServices(); const { data: services, isLoading, isError } = useServices();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Service[] | null>(null); const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: reorderServicesRequest, mutationFn: reorderServicesRequest,
@@ -318,9 +496,46 @@ export function ServicesPage() {
onError: () => setLocalOrder(null), onError: () => setLocalOrder(null),
}); });
const list = localOrder ?? services ?? []; const baseList = localOrder ?? services ?? [];
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0; const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
const list = useMemo(() => {
if (!sortColumn) return baseList;
const sorted = [...baseList].sort((a, b) => {
let cmp = 0;
switch (sortColumn) {
case "displayName":
cmp = a.displayName.localeCompare(b.displayName);
break;
case "category":
cmp = (a.category ?? "").localeCompare(b.category ?? "");
break;
case "alias":
cmp = a.alias.join(",").localeCompare(b.alias.join(","));
break;
case "port":
cmp = a.port - b.port;
break;
case "https":
cmp = Number(a.https) - Number(b.https);
break;
}
return sortDirection === "asc" ? cmp : -cmp;
});
return sorted;
}, [baseList, sortColumn, sortDirection]);
function handleHeaderClick(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortColumn(column);
setSortDirection("asc");
}
}
const dragEnabled = sortColumn === null;
function handleDragStart(id: string) { function handleDragStart(id: string) {
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id); return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
} }
@@ -328,7 +543,7 @@ export function ServicesPage() {
function handleDragOver(targetId: string) { function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLTableRowElement>) => { return (e: DragEvent<HTMLTableRowElement>) => {
e.preventDefault(); e.preventDefault();
if (!draggedId || draggedId === targetId) return; if (!dragEnabled || !draggedId || draggedId === targetId) return;
const current = localOrder ?? services ?? []; const current = localOrder ?? services ?? [];
const fromIndex = current.findIndex((s) => s.id === draggedId); const fromIndex = current.findIndex((s) => s.id === draggedId);
@@ -345,6 +560,7 @@ export function ServicesPage() {
function handleDrop() { function handleDrop() {
return (e: DragEvent<HTMLTableRowElement>) => { return (e: DragEvent<HTMLTableRowElement>) => {
e.preventDefault(); e.preventDefault();
if (!dragEnabled) return;
setDraggedId(null); setDraggedId(null);
const current = localOrder ?? services ?? []; const current = localOrder ?? services ?? [];
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index }))); reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
@@ -357,11 +573,21 @@ export function ServicesPage() {
title="Dienste" title="Dienste"
description={ description={
hiddenCount > 0 hiddenCount > 0
? `Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).` ? `Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
: "Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." : "Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
} }
/> />
<ImportExportBar />
{sortColumn ? (
<div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
Zur manuellen Reihenfolge (Drag & Drop) zurück
</Button>
</div>
) : null}
{isLoading ? ( {isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p> <p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p>
) : isError ? ( ) : isError ? (
@@ -375,11 +601,11 @@ export function ServicesPage() {
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40"> uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-2 py-2" /> <th className="px-2 py-2" />
<th className="px-2 py-2" /> <th className="px-2 py-2" />
<th className="px-4 py-2 font-medium">Dienst</th> <SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Kategorie</th> <SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Alias</th> <SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Port</th> <SortableHeader label="Port" column="port" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Protokoll</th> <SortableHeader label="Protokoll" column="https" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">URL</th> <th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2" /> <th className="px-4 py-2" />
</tr> </tr>
@@ -389,6 +615,7 @@ export function ServicesPage() {
<ServiceRow <ServiceRow
key={service.id} key={service.id}
service={service} service={service}
draggable={dragEnabled}
isDragging={draggedId === service.id} isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)} onDragStart={handleDragStart(service.id)}
onDragOver={handleDragOver(service.id)} onDragOver={handleDragOver(service.id)}
@@ -402,7 +629,7 @@ export function ServicesPage() {
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40"> <p className="text-sm text-black/40 dark:text-white/40">
Noch keine Dienste vorhanden. Scanne ein Gerät unter Geräte, um automatisch welche Noch keine Dienste vorhanden. Scanne ein Gerät unter Geräte, um automatisch welche
zu finden. zu finden, oder importiere eine Liste oben.
</p> </p>
)} )}
</div> </div>

View File

@@ -232,3 +232,43 @@ komplett offline funktionierende Variante ersetzt.
> Test mit echtem nginx: `/ca.crt` per HTTP heruntergeladen und **damit ohne > Test mit echtem nginx: `/ca.crt` per HTTP heruntergeladen und **damit ohne
> `-k`-Flag** eine echte HTTPS-Verbindung erfolgreich validiert simuliert > `-k`-Flag** eine echte HTTPS-Verbindung erfolgreich validiert simuliert
> exakt das Verhalten eines Geräts nach CA-Import. > 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<T extends Rankable>` 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.

View File

@@ -73,6 +73,44 @@ export interface PluginInfo {
capabilities: string[]; 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: * Ranking-Stufen für die Suche, gemäß Spezifikation:
* 1. Displayname beginnt mit Suchtext * 1. Displayname beginnt mit Suchtext
@@ -83,15 +121,17 @@ export interface PluginInfo {
* 6. Beschreibung enthält Suchtext * 6. Beschreibung enthält Suchtext
* *
* Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer. * 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<T extends Rankable>(item: T, query: string): number | null {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (q.length === 0) return null; if (q.length === 0) return null;
const displayName = service.displayName.toLowerCase(); const displayName = item.displayName.toLowerCase();
const hostname = service.hostname.toLowerCase(); const hostname = item.hostname.toLowerCase();
const description = (service.description ?? "").toLowerCase(); const description = (item.description ?? "").toLowerCase();
const alias = service.alias.map((a) => a.toLowerCase()); const alias = item.alias.map((a) => a.toLowerCase());
if (displayName.startsWith(q)) return 1; if (displayName.startsWith(q)) return 1;
if (alias.some((a) => a.startsWith(q))) return 2; 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. * Sortiert und filtert eine Liste (Dienste, Lesezeichen oder eine Mischung
* Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge. * ü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<T extends Rankable>(items: T[], query: string): T[] {
const q = query.trim(); const q = query.trim();
if (q.length === 0) { if (q.length === 0) {
return [...services].sort((a, b) => { return [...items].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return a.order - b.order; return a.order - b.order;
}); });
} }
return services return items
.map((service) => ({ service, rank: rankService(service, q) })) .map((item) => ({ item, rank: rankService(item, q) }))
.filter((entry): entry is { service: Service; rank: number } => entry.rank !== null) .filter((entry): entry is { item: T; rank: number } => entry.rank !== null)
.sort((a, b) => { .sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank; if (a.rank !== b.rank) return a.rank - b.rank;
if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1; if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
return a.service.order - b.service.order; return a.item.order - b.item.order;
}) })
.map((entry) => entry.service); .map((entry) => entry.item);
} }

View File

@@ -78,3 +78,31 @@ export const CategoryReorderSchema = z
) )
.min(1, "mindestens ein Eintrag erforderlich"); .min(1, "mindestens ein Eintrag erforderlich");
export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>; export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>;
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<typeof BookmarkCreateSchema>;
export const BookmarkUpdateSchema = BookmarkCreateSchema.partial();
export type BookmarkUpdateInput = z.infer<typeof BookmarkUpdateSchema>;
/** 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<typeof BookmarkReorderSchema>;

View File

@@ -0,0 +1,41 @@
export interface FaviconProps {
src?: string | null;
fallbackLetter: string;
size?: "sm" | "md";
}
const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, 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 (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50`}
>
{fallbackLetter.charAt(0).toUpperCase()}
</span>
);
}
return (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-white p-0.5 ring-1 ring-black/5`}
>
<img src={src} alt="" className="h-full w-full object-contain" />
</span>
);
}

View File

@@ -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 { export interface FavoritesBarProps {
services: Service[]; items: FavoriteItem[];
onOpen: (service: Service) => void; 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 * Zeigt Favoriten als anklickbare, per Drag & Drop sortierbare Chips immer
* Suchfeld. Reihenfolge folgt service.order (im Adminbereich per Drag & Drop * sichtbar, unabhängig vom Suchfeld. Dienste und Lesezeichen werden über
* änderbar). * getrennte FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein
* generisch über FavoriteItem statt fest an Service gebunden.
*/ */
export function FavoritesBar({ services, onOpen }: FavoritesBarProps) { export function FavoritesBar({ items, label, onOpen, onReorder }: FavoritesBarProps) {
if (services.length === 0) return null; const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<FavoriteItem[] | null>(null);
if (items.length === 0) return null;
const list = localOrder ?? items;
function handleDragStart(id: string) {
return (_e: DragEvent<HTMLButtonElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) {
e.preventDefault();
setDraggedId(null);
const current = localOrder ?? items;
onReorder?.(current.map((i) => i.id));
}
return ( return (
<div>
{label ? (
<div className="mb-1.5 text-center text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
{label}
</div>
) : null}
<div <div
role="toolbar" role="toolbar"
aria-label="Favoriten" aria-label={label ?? "Favoriten"}
className="flex flex-wrap items-center justify-center gap-2" className="flex flex-wrap items-center justify-center gap-2"
> >
{services.map((service) => ( {list.map((item) => (
<button <button
key={service.id} key={item.id}
type="button" type="button"
onClick={() => onOpen(service)} draggable={!!onReorder}
title={`${service.displayName} (${service.hostname}:${service.port})`} onDragStart={handleDragStart(item.id)}
className="flex items-center gap-2 rounded-full border border-black/10 bg-white/70 onDragOver={handleDragOver(item.id)}
onDrop={handleDrop}
onClick={() => onOpen(item)}
title={
item.port !== undefined
? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})`
}
className={`flex items-center gap-2 rounded-full border border-black/10 bg-white/70
px-3 py-1.5 text-sm text-black transition-colors hover:bg-black/5 px-3 py-1.5 text-sm text-black transition-colors hover:bg-black/5
dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10" dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10 ${
onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`}
> >
{service.favicon ? ( <Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<img src={service.favicon} alt="" className="h-4 w-4 shrink-0 rounded" /> <span className="max-w-[10rem] truncate">{item.displayName}</span>
) : (
<span
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full
bg-black/10 text-[9px] font-medium text-black/50 dark:bg-white/10 dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<span className="max-w-[10rem] truncate">{service.displayName}</span>
</button> </button>
))} ))}
</div> </div>
</div>
); );
} }

View File

@@ -1,29 +1,31 @@
import type { KeyboardEvent } from "react"; 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 { export interface ResultsListProps {
services: Service[]; results: SearchResult[];
selectedIndex: number; selectedIndex: number;
emptyLabel?: string; emptyLabel?: string;
onHover: (index: number) => void; onHover: (index: number) => void;
onOpen: (service: Service) => void; onOpen: (item: SearchResult) => void;
onToggleFavorite?: (service: Service) => void; onToggleFavorite?: (item: SearchResult) => void;
} }
/** /**
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an. * Zeigt die (bereits per rankServices sortierten) Suchtreffer an Dienste
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement * und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar. * Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert;
* diese Komponente ist rein darstellend + klick-/tastaturbar.
*/ */
export function ResultsList({ export function ResultsList({
services, results,
selectedIndex, selectedIndex,
emptyLabel = "Keine Dienste gefunden.", emptyLabel = "Keine Treffer gefunden.",
onHover, onHover,
onOpen, onOpen,
onToggleFavorite, onToggleFavorite,
}: ResultsListProps) { }: ResultsListProps) {
if (services.length === 0) { if (results.length === 0) {
return ( return (
<div <div
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
@@ -41,16 +43,19 @@ export function ResultsList({
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10 border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
dark:bg-white/5" dark:bg-white/5"
> >
{services.map((service, index) => { {results.map((item, index) => {
const active = index === selectedIndex; const active = index === selectedIndex;
const subtitle =
item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname;
return ( return (
<li key={service.id} role="option" aria-selected={active}> <li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
<div <div
tabIndex={-1} tabIndex={-1}
onMouseEnter={() => onHover(index)} onMouseEnter={() => onHover(index)}
onClick={() => onOpen(service)} onClick={() => onOpen(item)}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => { onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
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 className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
transition-colors ${ transition-colors ${
@@ -59,30 +64,27 @@ export function ResultsList({
: "hover:bg-black/[0.03] dark:hover:bg-white/5" : "hover:bg-black/[0.03] dark:hover:bg-white/5"
}`} }`}
> >
{service.favicon ? ( <Favicon src={item.favicon} fallbackLetter={item.displayName} />
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
) : (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="block truncate text-sm font-medium text-black dark:text-white"> <span className="block truncate text-sm font-medium text-black dark:text-white">
{service.displayName} {item.displayName}
</span>
{item.kind === "bookmark" ? (
<span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/40 dark:bg-white/10 dark:text-white/40">
Lesezeichen
</span>
) : null}
</span> </span>
<span className="block truncate text-xs text-black/40 dark:text-white/40"> <span className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname}:{service.port} {subtitle}
</span> </span>
</span> </span>
{service.category ? ( {item.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30"> <span className="shrink-0 text-xs text-black/30 dark:text-white/30">
{service.category} {item.category}
</span> </span>
) : null} ) : null}
@@ -90,15 +92,13 @@ export function ResultsList({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleFavorite?.(service); onToggleFavorite?.(item);
}} }}
disabled={!onToggleFavorite} disabled={!onToggleFavorite}
aria-pressed={service.favorite} aria-pressed={item.favorite}
aria-label={ aria-label={item.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"}
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
}
className={`shrink-0 text-lg leading-none transition-colors ${ className={`shrink-0 text-lg leading-none transition-colors ${
service.favorite item.favorite
? "text-amber-500" ? "text-amber-500"
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400" : "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
} ${onToggleFavorite ? "" : "cursor-default"}`} } ${onToggleFavorite ? "" : "cursor-default"}`}

View File

@@ -12,3 +12,6 @@ export type { ButtonProps } from "./Button.js";
export { FavoritesBar } from "./FavoritesBar.js"; export { FavoritesBar } from "./FavoritesBar.js";
export type { FavoritesBarProps } from "./FavoritesBar.js"; export type { FavoritesBarProps } from "./FavoritesBar.js";
export { Favicon } from "./Favicon.js";
export type { FaviconProps } from "./Favicon.js";

132
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
fastify: fastify:
specifier: ^4.28.1 specifier: ^4.28.1
version: 4.29.1 version: 4.29.1
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: ^7.6.11 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)) version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.49.0))
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.5.4(postcss@8.5.19) version: 10.5.4(postcss@8.5.20)
postcss: postcss:
specifier: ^8.4.41 specifier: ^8.4.41
version: 8.5.19 version: 8.5.20
tailwindcss: tailwindcss:
specifier: ^3.4.10 specifier: ^3.4.10
version: 3.4.19(tsx@4.23.1) version: 3.4.19(tsx@4.23.1)
@@ -1549,6 +1552,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv-formats@2.1.1: ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies: peerDependencies:
@@ -1702,6 +1709,10 @@ packages:
caniuse-lite@1.0.30001806: caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} 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: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -1709,6 +1720,10 @@ packages:
chownr@1.1.4: chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} 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: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -1736,6 +1751,11 @@ packages:
core-js-compat@3.49.0: core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} 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: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2026,11 +2046,11 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@2.4.2: fast-uri@2.4.3:
resolution: {integrity: sha512-Ll1wlF3LBJ2+vFEeTSH9SFrjnXJorZQexrn0yHa4BJdGS+FFkWW3xU/YuIdmdyloSDuUgTYh/YeY/vUNcdCS/g==} resolution: {integrity: sha512-8V8UrSDUkYpi4AXM7Na0G6hctXSaRHBGMuANOotuFdHEFtTdqDTRNfcDczA9WkKODI17o7o10iQvzdMIxXb8eA==}
fast-uri@3.1.3: fast-uri@3.1.4:
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fastify-plugin@4.5.1: fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
@@ -2076,6 +2096,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -2602,8 +2626,8 @@ packages:
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.19: postcss@8.5.20:
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3: prebuild-install@7.1.3:
@@ -2867,6 +2891,10 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} 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: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3120,6 +3148,14 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true 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: workbox-background-sync@7.4.1:
resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==}
@@ -3172,6 +3208,11 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4159,7 +4200,7 @@ snapshots:
dependencies: dependencies:
ajv: 8.20.0 ajv: 8.20.0
ajv-formats: 2.1.1(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': '@fastify/cors@9.0.1':
dependencies: dependencies:
@@ -4444,6 +4485,8 @@ snapshots:
acorn@8.17.0: {} acorn@8.17.0: {}
adler-32@1.3.1: {}
ajv-formats@2.1.1(ajv@8.20.0): ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies: optionalDependencies:
ajv: 8.20.0 ajv: 8.20.0
@@ -4455,7 +4498,7 @@ snapshots:
ajv@8.20.0: ajv@8.20.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 3.1.3 fast-uri: 3.1.4
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
@@ -4491,13 +4534,13 @@ snapshots:
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
autoprefixer@10.5.4(postcss@8.5.19): autoprefixer@10.5.4(postcss@8.5.20):
dependencies: dependencies:
browserslist: 4.28.6 browserslist: 4.28.6
caniuse-lite: 1.0.30001806 caniuse-lite: 1.0.30001806
fraction.js: 5.3.4 fraction.js: 5.3.4
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.19 postcss: 8.5.20
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
@@ -4611,6 +4654,11 @@ snapshots:
caniuse-lite@1.0.30001806: {} caniuse-lite@1.0.30001806: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -4625,6 +4673,8 @@ snapshots:
chownr@1.1.4: {} chownr@1.1.4: {}
codepage@1.15.0: {}
colorette@2.0.20: {} colorette@2.0.20: {}
commander@2.20.3: {} commander@2.20.3: {}
@@ -4643,6 +4693,8 @@ snapshots:
dependencies: dependencies:
browserslist: 4.28.6 browserslist: 4.28.6
crc-32@1.2.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -4978,7 +5030,7 @@ snapshots:
ajv: 8.20.0 ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 2.4.2 fast-uri: 2.4.3
json-schema-ref-resolver: 1.0.1 json-schema-ref-resolver: 1.0.1
rfdc: 1.4.1 rfdc: 1.4.1
@@ -4988,9 +5040,9 @@ snapshots:
fast-safe-stringify@2.1.1: {} 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: {} fastify-plugin@4.5.1: {}
@@ -5048,6 +5100,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fs-constants@1.0.0: {} fs-constants@1.0.0: {}
@@ -5512,29 +5566,29 @@ snapshots:
possible-typed-array-names@1.1.0: {} 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: dependencies:
postcss: 8.5.19 postcss: 8.5.20
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.12 resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.19): postcss-js@4.1.0(postcss@8.5.20):
dependencies: dependencies:
camelcase-css: 2.0.1 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: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
optionalDependencies: optionalDependencies:
jiti: 1.21.7 jiti: 1.21.7
postcss: 8.5.19 postcss: 8.5.20
tsx: 4.23.1 tsx: 4.23.1
postcss-nested@6.2.0(postcss@8.5.19): postcss-nested@6.2.0(postcss@8.5.20):
dependencies: dependencies:
postcss: 8.5.19 postcss: 8.5.20
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4: postcss-selector-parser@6.1.4:
@@ -5544,7 +5598,7 @@ snapshots:
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.19: postcss@8.5.20:
dependencies: dependencies:
nanoid: 3.3.16 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
@@ -5861,6 +5915,10 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5950,11 +6008,11 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.19 postcss: 8.5.20
postcss-import: 15.1.0(postcss@8.5.19) postcss-import: 15.1.0(postcss@8.5.20)
postcss-js: 4.1.0(postcss@8.5.19) postcss-js: 4.1.0(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)
postcss-nested: 6.2.0(postcss@8.5.19) postcss-nested: 6.2.0(postcss@8.5.20)
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
resolve: 1.22.12 resolve: 1.22.12
sucrase: 3.35.1 sucrase: 3.35.1
@@ -6123,7 +6181,7 @@ snapshots:
vite@5.4.21(@types/node@20.19.43)(terser@5.49.0): vite@5.4.21(@types/node@20.19.43)(terser@5.49.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.19 postcss: 8.5.20
rollup: 4.62.2 rollup: 4.62.2
optionalDependencies: optionalDependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
@@ -6183,6 +6241,10 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wmf@1.0.2: {}
word@0.3.0: {}
workbox-background-sync@7.4.1: workbox-background-sync@7.4.1:
dependencies: dependencies:
idb: 7.1.1 idb: 7.1.1
@@ -6298,6 +6360,16 @@ snapshots:
wrappy@1.0.2: {} 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: {} yallist@3.1.1: {}
zod@3.25.76: {} zod@3.25.76: {}