diff --git a/README.md b/README.md index 3740415..9c849f1 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,16 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst: mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`) - ✅ Suche im Frontend gegen echte Backend-Daten (TanStack Query), inkl. Ranking-Logik aus `packages/shared` und Tastatur-Navigation (Pfeiltasten, Enter, Escape) +- ✅ Kategorien-API (`/api/categories`), inkl. Umbenennen, Löschen (Dienste behalten + ihre Zuordnung nicht, werden aber nicht gelöscht) und Bulk-Reorder für Drag & Drop +- ✅ Favoriten-Toggle direkt in der Trefferliste (Stern anklicken) Noch **nicht** enthalten (folgt in den nächsten Commits): - Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung) -- Kategorien-Verwaltung & Favoriten-Toggle im UI -- Adminbereich, TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF +- Adminbereich mit Kategorien-Verwaltungsoberfläche (Erstellen, Umbenennen, + Drag & Drop) — Backend-API dafür existiert bereits +- TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge. @@ -86,6 +90,13 @@ GET /api/services/:id POST /api/services erfordert existierende deviceId PATCH /api/services/:id DELETE /api/services/:id + +GET /api/categories +POST /api/categories +PATCH /api/categories/reorder Body: [{ id, order }, ...] +PATCH /api/categories/:id Umbenennen +DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), + werden aber nicht gelöscht ``` ## Deployment auf dem Server (xlc-launchpad) diff --git a/apps/backend/src/db/repositories/categories.ts b/apps/backend/src/db/repositories/categories.ts new file mode 100644 index 0000000..a328e1c --- /dev/null +++ b/apps/backend/src/db/repositories/categories.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { + Category, + CategoryCreateInput, + CategoryReorderInput, + CategoryUpdateInput, +} from "@launchpad/shared"; +import { db } from "../client.js"; +import { categories, services } from "../schema.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function mapRow(row: typeof categories.$inferSelect): Category { + return { id: row.id, name: row.name, order: row.order }; +} + +export function listCategories(): Category[] { + return db + .select() + .from(categories) + .all() + .map(mapRow) + .sort((a, b) => a.order - b.order); +} + +export function getCategory(id: string): Category | null { + const row = db.select().from(categories).where(eq(categories.id, id)).get(); + return row ? mapRow(row) : null; +} + +export function createCategory(input: CategoryCreateInput): Category { + const id = randomUUID(); + const timestamp = nowIso(); + const existing = listCategories(); + const nextOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.order)) + 1 : 0; + + db.insert(categories) + .values({ + id, + name: input.name, + order: nextOrder, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return getCategory(id)!; +} + +export function updateCategory(id: string, input: CategoryUpdateInput): Category | null { + const existing = getCategory(id); + if (!existing) return null; + + db.update(categories) + .set({ + ...(input.name !== undefined && { name: input.name }), + updatedAt: nowIso(), + }) + .where(eq(categories.id, id)) + .run(); + + return getCategory(id); +} + +/** + * Setzt die Reihenfolge mehrerer Kategorien in einer Transaktion neu, + * z. B. nach Drag & Drop in der Admin-UI (siehe Commit 6). + */ +export function reorderCategories(input: CategoryReorderInput): Category[] { + db.transaction((tx) => { + for (const entry of input) { + tx.update(categories) + .set({ order: entry.order, updatedAt: nowIso() }) + .where(eq(categories.id, entry.id)) + .run(); + } + }); + + return listCategories(); +} + +/** + * Löscht eine Kategorie. Dienste, die dieser Kategorie zugeordnet waren, + * verlieren ihre Kategoriezuordnung (category wird auf null gesetzt), + * werden aber nicht selbst gelöscht. + */ +export function deleteCategory(id: string): boolean { + const category = getCategory(id); + if (!category) return false; + + db.update(services) + .set({ category: null, updatedAt: nowIso() }) + .where(eq(services.category, category.name)) + .run(); + + const result = db.delete(categories).where(eq(categories.id, id)).run(); + return result.changes > 0; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index fe4798b..5635520 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -4,6 +4,7 @@ import { ensureSchema } from "./db/client.js"; import { healthRoutes } from "./routes/health.js"; import { deviceRoutes } from "./routes/devices.js"; import { serviceRoutes } from "./routes/services.js"; +import { categoryRoutes } from "./routes/categories.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -28,6 +29,7 @@ async function main() { await app.register(healthRoutes); await app.register(deviceRoutes); await app.register(serviceRoutes); + await app.register(categoryRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts new file mode 100644 index 0000000..7817fcc --- /dev/null +++ b/apps/backend/src/routes/categories.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance } from "fastify"; +import { + CategoryCreateSchema, + CategoryReorderSchema, + CategoryUpdateSchema, +} from "@launchpad/shared"; +import * as categoryRepo from "../db/repositories/categories.js"; + +export async function categoryRoutes(app: FastifyInstance): Promise { + app.get("/api/categories", async () => { + return categoryRepo.listCategories(); + }); + + app.post("/api/categories", async (request, reply) => { + const parsed = CategoryCreateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + const category = categoryRepo.createCategory(parsed.data); + return reply.code(201).send(category); + }); + + // Muss vor der /:id-Route registriert sein, damit "reorder" nicht als ID interpretiert wird. + app.patch("/api/categories/reorder", async (request, reply) => { + const parsed = CategoryReorderSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + return categoryRepo.reorderCategories(parsed.data); + }); + + app.patch("/api/categories/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = CategoryUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + const category = categoryRepo.updateCategory(id, parsed.data); + if (!category) { + return reply.code(404).send({ error: "Kategorie nicht gefunden" }); + } + return category; + }); + + app.delete("/api/categories/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = categoryRepo.deleteCategory(id); + if (!deleted) { + return reply.code(404).send({ error: "Kategorie nicht gefunden" }); + } + return reply.code(204).send(); + }); +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index f671159..e4a446a 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui"; import { rankServices, type Service, type HealthStatus } from "@launchpad/shared"; import { useServices } from "./hooks/useServices.js"; @@ -60,6 +61,18 @@ function openService(service: Service) { window.open(service.url, "_blank", "noopener,noreferrer"); } +async function toggleServiceFavorite(service: Service): Promise { + const res = await fetch(`/api/services/${service.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ favorite: !service.favorite }), + }); + if (!res.ok) { + throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`); + } + return res.json(); +} + export default function App() { const [theme, toggleTheme] = useTheme(); const [query, setQuery] = useState(""); @@ -67,6 +80,14 @@ export default function App() { const { health, error: healthError } = useBackendHealth(); const { data: services, isLoading, isError } = useServices(); const inputRef = useRef(null); + const queryClient = useQueryClient(); + + const toggleFavorite = useMutation({ + mutationFn: toggleServiceFavorite, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["services"] }); + }, + }); const results = useMemo( () => rankServices(services ?? [], query), @@ -157,6 +178,7 @@ export default function App() { selectedIndex={selectedIndex} onHover={setSelectedIndex} onOpen={openService} + onToggleFavorite={(service) => toggleFavorite.mutate(service)} emptyLabel={ (services?.length ?? 0) === 0 ? "Noch keine Dienste angelegt. Füge welche über die API hinzu." diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1ef0ec0..76d58c8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,10 +19,17 @@ Grundgerüst aus Commit 1. > TanStack Router wird bewusst erst mit dem Adminbereich (Commit 6) eingeführt, > da es vorher keine zweite Route gibt, die er sinnvoll verwalten könnte. -## Commit 4 — Kategorien & Favoriten +## ✅ Commit 4 — Kategorien & Favoriten (erledigt) -- Kategorien: Erstellen, Löschen, Umbenennen, Drag & Drop-Sortierung -- Favoriten-Toggle, Favoriten erscheinen zuerst +- Kategorien-API: Erstellen, Umbenennen, Löschen, Bulk-Reorder (`PATCH /api/categories/reorder`) +- Löschen einer Kategorie setzt `category` bei betroffenen Diensten auf `null`, + löscht die Dienste aber nicht +- Favoriten-Toggle direkt in der Trefferliste des Frontends (Stern anklicken, + per `useMutation` + Cache-Invalidierung) + +> Die eigentliche Kategorien-*Verwaltungsoberfläche* (Erstellen/Umbenennen/ +> Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) – die Startseite +> bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort. ## Commit 5 — Scanner diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2884b8a..67c3148 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -46,6 +46,12 @@ export interface HealthStatus { version: string; } +export interface Category { + id: string; + name: string; + order: number; +} + /** * Ranking-Stufen für die Suche, gemäß Spezifikation: * 1. Displayname beginnt mit Suchtext diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 17613ab..596892f 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -46,3 +46,22 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({ deviceId: true, }).partial(); export type ServiceUpdateInput = z.infer; + +export const CategoryCreateSchema = z.object({ + name: z.string().min(1, "name darf nicht leer sein"), +}); +export type CategoryCreateInput = z.infer; + +export const CategoryUpdateSchema = CategoryCreateSchema.partial(); +export type CategoryUpdateInput = z.infer; + +/** Für Drag & Drop: neue Reihenfolge mehrerer Kategorien auf einmal setzen. */ +export const CategoryReorderSchema = z + .array( + z.object({ + id: z.string().min(1), + order: z.number(), + }) + ) + .min(1, "mindestens ein Eintrag erforderlich"); +export type CategoryReorderInput = z.infer; diff --git a/packages/ui/src/ResultsList.tsx b/packages/ui/src/ResultsList.tsx index 953f07f..d3d2673 100644 --- a/packages/ui/src/ResultsList.tsx +++ b/packages/ui/src/ResultsList.tsx @@ -1,3 +1,4 @@ +import type { KeyboardEvent } from "react"; import type { Service } from "@launchpad/shared"; export interface ResultsListProps { @@ -6,12 +7,13 @@ export interface ResultsListProps { emptyLabel?: string; onHover: (index: number) => void; onOpen: (service: Service) => void; + onToggleFavorite?: (service: Service) => void; } /** * Zeigt die (bereits per rankServices sortierten) Suchtreffer an. * Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement - * gesteuert; diese Komponente ist rein darstellend + klickbar. + * gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar. */ export function ResultsList({ services, @@ -19,6 +21,7 @@ export function ResultsList({ emptyLabel = "Keine Dienste gefunden.", onHover, onOpen, + onToggleFavorite, }: ResultsListProps) { if (services.length === 0) { return ( @@ -42,15 +45,19 @@ export function ResultsList({ const active = index === selectedIndex; return (
  • - + +
  • ); })}