From a17f32c3782eee0e076d3414712bca2e8773e413 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 00:33:57 +0200 Subject: [PATCH] feat(ui): Export und Import in der Oberflaeche Dashboard: ein Datenmenue neben "Neues Projekt" mit Export aller Projekte und Import. Projektseite: Ausklappmenue mit JSON und Markdown. Adminbereich: Abschnitt "Sicherung" mit deutlichem Hinweis, was in der Datei steht und wie vertraulich sie zu behandeln ist. Der Import-Dialog fuehrt in zwei Schritten: Datei waehlen, Vorschau ansehen, erst dann schreiben. Ein Wechsel der Strategie berechnet die Vorschau neu. Der Ausfuehren-Knopf bleibt gesperrt, solange sich nichts aendern wuerde - sonst waere er ein Angebot, das ins Leere laeuft. Fehlende Git-Verbindungen werden gesondert hervorgehoben, mit dem Hinweis, dass Tokens nie in Exportdateien stehen. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ImportDialog.tsx | 326 ++++++++++++++++++++++++++ src/components/ProjectBoard.tsx | 75 +++++- src/components/ProjectHeader.tsx | 57 ++++- src/components/admin/AdminManager.tsx | 37 +++ 4 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 src/components/ImportDialog.tsx diff --git a/src/components/ImportDialog.tsx b/src/components/ImportDialog.tsx new file mode 100644 index 0000000..982665d --- /dev/null +++ b/src/components/ImportDialog.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + AlertTriangle, + ArrowRight, + CheckCircle2, + FileJson, + Loader2, + Upload, +} from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; +import { cn } from "@/lib/utils"; + +type Strategy = "skip" | "rename" | "overwrite"; + +interface ItemResult { + name: string; + slug: string; + action: "create" | "rename" | "overwrite" | "skip" | "error"; + newSlug?: string; + notes: number; + connection: "matched" | "missing" | "none"; + connectionName?: string; + message?: string; +} + +interface ImportResponse { + dryRun: boolean; + strategy: Strategy; + summary: { + total: number; + created: number; + renamed: number; + overwritten: number; + skipped: number; + errors: number; + missingConnections: number; + }; + results: ItemResult[]; +} + +const STRATEGIES: Array<{ value: Strategy; label: string; hint: string }> = [ + { value: "skip", label: "Ueberspringen", hint: "Vorhandene bleiben unveraendert." }, + { value: "rename", label: "Zusaetzlich anlegen", hint: "Doppelte bekommen einen freien Slug." }, + { value: "overwrite", label: "Ersetzen", hint: "Stammdaten und Notizen werden ueberschrieben." }, +]; + +const ACTION_LABEL: Record = { + create: { text: "neu", className: "bg-emerald-500/12 text-emerald-200" }, + rename: { text: "zusaetzlich", className: "bg-cyan-500/12 text-cyan-200" }, + overwrite: { text: "ersetzt", className: "bg-amber-500/12 text-amber-200" }, + skip: { text: "uebersprungen", className: "bg-slate-500/12 text-slate-300" }, + error: { text: "Fehler", className: "bg-rose-500/12 text-rose-200" }, +}; + +export function ImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const router = useRouter(); + const fileRef = useRef(null); + const [fileName, setFileName] = useState(null); + const [payload, setPayload] = useState(null); + const [strategy, setStrategy] = useState("skip"); + const [preview, setPreview] = useState(null); + const [done, setDone] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function reset() { + setFileName(null); + setPayload(null); + setPreview(null); + setDone(null); + setError(null); + setStrategy("skip"); + if (fileRef.current) fileRef.current.value = ""; + } + + function close() { + reset(); + onClose(); + } + + async function pickFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + setError(null); + setPreview(null); + setDone(null); + setFileName(file.name); + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + setPayload(parsed); + await run(parsed, strategy, true); + } catch { + setPayload(null); + setError("Die Datei enthaelt kein gueltiges JSON."); + } + } + + async function run(data: unknown, chosen: Strategy, dryRun: boolean) { + setBusy(true); + setError(null); + try { + const response = await fetch("/api/projects/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data, strategy: chosen, dryRun }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Import fehlgeschlagen."); + + if (dryRun) setPreview(body as ImportResponse); + else { + setDone(body as ImportResponse); + router.refresh(); + } + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + async function changeStrategy(next: Strategy) { + setStrategy(next); + if (payload) await run(payload, next, true); + } + + const summary = done?.summary ?? preview?.summary; + const results = done?.results ?? preview?.results ?? []; + + // Wuerde der Import ueberhaupt etwas veraendern? Sonst waere der Knopf ein + // Angebot, das ins Leere laeuft. + const wouldChange = preview + ? preview.summary.created + preview.summary.renamed + preview.summary.overwritten > 0 + : false; + + return ( + +
+ {/* Datei */} +
+ + +
+ + {/* Strategie */} + {payload ? ( +
+ Bei gleichem Slug +
+ {STRATEGIES.map((option) => ( + + ))} +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {/* Vorschau oder Ergebnis */} + {summary ? ( +
+
+ + {done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e) + + {summary.created > 0 ? {summary.created} neu : null} + {summary.renamed > 0 ? {summary.renamed} zusaetzlich : null} + {summary.overwritten > 0 ? ( + {summary.overwritten} ersetzt + ) : null} + {summary.skipped > 0 ? {summary.skipped} uebersprungen : null} + {summary.errors > 0 ? {summary.errors} Fehler : null} +
+ + {summary.missingConnections > 0 ? ( +

+ + + {summary.missingConnections} Projekt(e) verweisen auf eine Git-Verbindung, + die hier nicht existiert. Sie werden ohne Verknuepfung angelegt – lege die + Verbindung an und waehle sie im Projekt aus. Access Tokens sind in + Exportdateien grundsaetzlich nicht enthalten. + +

+ ) : null} + +
+ {results.map((item) => { + const label = ACTION_LABEL[item.action]; + return ( +
+
+

{item.name}

+

+ {item.slug} + {item.newSlug ? ( + <> + + {item.newSlug} + + ) : null} + · {item.notes} Notizen + {item.connection === "missing" ? ( + + · Verbindung „{item.connectionName}“ fehlt + + ) : null} +

+ {item.message ? ( +

{item.message}

+ ) : null} +
+ + {label.text} + +
+ ); + })} +
+
+ ) : null} + + {done ? ( +

+ + Import abgeschlossen. +

+ ) : null} + +
+ + {!done ? ( + + ) : null} +
+
+
+ ); +} + +function Chip({ + tone, + children, +}: { + tone: "emerald" | "cyan" | "amber" | "slate" | "rose"; + children: React.ReactNode; +}) { + const tones = { + emerald: "bg-emerald-500/12 text-emerald-200", + cyan: "bg-cyan-500/12 text-cyan-200", + amber: "bg-amber-500/12 text-amber-200", + slate: "bg-slate-500/12 text-slate-300", + rose: "bg-rose-500/12 text-rose-200", + }; + return {children}; +} diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index b073845..b6029c7 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -1,10 +1,22 @@ "use client"; -import { useMemo, useState } from "react"; -import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { + Database, + Download, + LayoutGrid, + ListFilter, + Plus, + Rows3, + Search, + Upload, + X, +} from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; +import { ImportDialog } from "@/components/ImportDialog"; import { ProjectCard } from "@/components/ProjectCard"; import { ProjectDialog } from "@/components/ProjectDialog"; +import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER } from "@/lib/status"; import { cn } from "@/lib/utils"; import type { ConnectionSafe, ProjectListItem } from "@/types"; @@ -33,6 +45,9 @@ export function ProjectBoard({ const [view, setView] = useState("grid"); const [showArchived, setShowArchived] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [dataMenuOpen, setDataMenuOpen] = useState(false); + const dataButtonRef = useRef(null); const counts = useMemo(() => { const map = new Map(); @@ -165,6 +180,19 @@ export function ProjectBoard({ + + + + setDialogOpen(false)} connections={connections} /> + + setImportOpen(false)} /> ); } diff --git a/src/components/ProjectHeader.tsx b/src/components/ProjectHeader.tsx index 41d31e8..3eaf576 100644 --- a/src/components/ProjectHeader.tsx +++ b/src/components/ProjectHeader.tsx @@ -1,12 +1,15 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, CalendarDays, + Download, ExternalLink, + FileJson, + FileText, Loader2, Pencil, Star, @@ -14,6 +17,7 @@ import { } from "lucide-react"; import { ProjectDialog } from "@/components/ProjectDialog"; import { StatusSelect } from "@/components/StatusSelect"; +import { Popover } from "@/components/ui/Popover"; import { PRIORITY_META, accentOf } from "@/lib/status"; import { cn, formatDate } from "@/lib/utils"; import type { ConnectionSafe, ProjectDetail } from "@/types"; @@ -30,6 +34,8 @@ export function ProjectHeader({ const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const [editOpen, setEditOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const exportButtonRef = useRef(null); const [favorite, setFavorite] = useState(project.favorite); const [progress, setProgress] = useState(project.progress); const [deleting, setDeleting] = useState(false); @@ -162,6 +168,17 @@ export function ProjectHeader({
+
+ setExportOpen(false)} + width={250} + > + setExportOpen(false)} + className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + > + + + Als JSON + + Zum Wiedereinlesen, mit Notizen + + + + setExportOpen(false)} + className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + > + + + Als Markdown + + Lesbar, inklusive Git-Stand + + + + + setEditOpen(false)} diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx index 0adb0d6..d07d989 100644 --- a/src/components/admin/AdminManager.tsx +++ b/src/components/admin/AdminManager.tsx @@ -5,12 +5,15 @@ import { useRouter } from "next/navigation"; import { Ban, CheckCircle2, + Database, + Download, Fingerprint, FolderGit2, Loader2, Lock, Plus, RefreshCw, + ShieldAlert, ShieldCheck, Trash2, User, @@ -226,6 +229,40 @@ export function AdminManager({ data }: { data: AdminData }) { + {/* ── Sicherung ───────────────────────────────────── */} +
+
+ + + +
+

+ Sicherung +

+

+ Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten, + Verbindungen, Projekte und Notizen. +

+
+
+ + + + Sicherung herunterladen + + +

+ + + Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens + sind nur mit demselben APP_SECRET wieder lesbar, Passwoerter + liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine + byteweise Sicherung der Datenbank gibt es scripts/backup.ps1{" "} + beziehungsweise scripts/backup.sh mit pg_dump. + +

+
+ {/* ── Benutzer ────────────────────────────────────── */}