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) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 00:33:57 +02:00
co-authored by Claude Opus 5
parent 91c94e7383
commit a17f32c378
4 changed files with 492 additions and 3 deletions
+326
View File
@@ -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<ItemResult["action"], { text: string; className: string }> = {
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<HTMLInputElement>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [payload, setPayload] = useState<unknown>(null);
const [strategy, setStrategy] = useState<Strategy>("skip");
const [preview, setPreview] = useState<ImportResponse | null>(null);
const [done, setDone] = useState<ImportResponse | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement>) {
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 (
<Modal
open={open}
onClose={close}
width="xl"
title="Projekte importieren"
description="Eine zuvor exportierte JSON-Datei einlesen. Vor dem Schreiben gibt es eine Vorschau."
>
<div className="space-y-5">
{/* Datei */}
<div>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
onChange={pickFile}
className="hidden"
id="import-file"
/>
<button
type="button"
className="btn btn-ghost w-full justify-start py-3"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
<FileJson className="h-4 w-4 text-violet-300" />
{fileName ?? "JSON-Datei auswaehlen…"}
</button>
</div>
{/* Strategie */}
{payload ? (
<div>
<span className="field-label">Bei gleichem Slug</span>
<div className="grid gap-2 sm:grid-cols-3">
{STRATEGIES.map((option) => (
<button
key={option.value}
type="button"
disabled={busy || Boolean(done)}
onClick={() => changeStrategy(option.value)}
className={cn(
"rounded-xl border px-3 py-2.5 text-left transition-all disabled:opacity-60",
strategy === option.value
? "border-violet-400/60 bg-violet-500/12"
: "border-white/9 hover:border-white/20",
)}
>
<span className="block text-sm text-slate-100">{option.label}</span>
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
{option.hint}
</span>
</button>
))}
</div>
</div>
) : null}
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
{/* Vorschau oder Ergebnis */}
{summary ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-slate-400">
{done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e)
</span>
{summary.created > 0 ? <Chip tone="emerald">{summary.created} neu</Chip> : null}
{summary.renamed > 0 ? <Chip tone="cyan">{summary.renamed} zusaetzlich</Chip> : null}
{summary.overwritten > 0 ? (
<Chip tone="amber">{summary.overwritten} ersetzt</Chip>
) : null}
{summary.skipped > 0 ? <Chip tone="slate">{summary.skipped} uebersprungen</Chip> : null}
{summary.errors > 0 ? <Chip tone="rose">{summary.errors} Fehler</Chip> : null}
</div>
{summary.missingConnections > 0 ? (
<p className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
{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.
</span>
</p>
) : null}
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/8">
{results.map((item) => {
const label = ACTION_LABEL[item.action];
return (
<div
key={`${item.slug}-${item.name}`}
className="flex items-center justify-between gap-3 border-b border-white/5 px-3 py-2 last:border-b-0"
>
<div className="min-w-0">
<p className="truncate text-sm text-slate-200">{item.name}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-slate-500">
<span>{item.slug}</span>
{item.newSlug ? (
<>
<ArrowRight className="h-3 w-3" />
<span className="text-cyan-300">{item.newSlug}</span>
</>
) : null}
<span className="text-slate-600">· {item.notes} Notizen</span>
{item.connection === "missing" ? (
<span className="text-amber-300/80">
· Verbindung „{item.connectionName}“ fehlt
</span>
) : null}
</p>
{item.message ? (
<p className="mt-0.5 text-[11px] text-slate-600">{item.message}</p>
) : null}
</div>
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
label.className,
)}
>
{label.text}
</span>
</div>
);
})}
</div>
</div>
) : null}
{done ? (
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
<CheckCircle2 className="h-4 w-4" />
Import abgeschlossen.
</p>
) : null}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
{done ? "Schliessen" : "Abbrechen"}
</button>
{!done ? (
<button
type="button"
className="btn btn-primary"
disabled={busy || !wouldChange}
title={
preview && !wouldChange
? "Mit dieser Strategie wuerde sich nichts aendern."
: undefined
}
onClick={() => payload && run(payload, strategy, false)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Import ausfuehren
</button>
) : null}
</div>
</div>
</Modal>
);
}
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 <span className={cn("rounded px-1.5 py-0.5", tones[tone])}>{children}</span>;
}
+73 -2
View File
@@ -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<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null);
const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>();
@@ -165,6 +180,19 @@ export function ProjectBoard({
</button>
</div>
<button
ref={dataButtonRef}
type="button"
onClick={() => setDataMenuOpen((value) => !value)}
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
aria-label="Daten exportieren oder importieren"
title="Export und Import"
aria-haspopup="menu"
aria-expanded={dataMenuOpen}
>
<Database className="h-4 w-4" />
</button>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span>
@@ -267,11 +295,54 @@ export function ProjectBoard({
</div>
)}
<Popover
anchorRef={dataButtonRef}
open={dataMenuOpen}
onClose={() => setDataMenuOpen(false)}
width={260}
>
<a
href="/api/projects/export"
download
role="menuitem"
onClick={() => setDataMenuOpen(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"
>
<Download className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Alle Projekte exportieren
<span className="mt-0.5 block text-[11px] text-slate-500">
JSON mit Notizen, ohne Access Tokens
</span>
</span>
</a>
<button
type="button"
role="menuitem"
onClick={() => {
setDataMenuOpen(false);
setImportOpen(true);
}}
className="flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<Upload className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>
Projekte importieren
<span className="mt-0.5 block text-[11px] text-slate-500">
Mit Vorschau vor dem Schreiben
</span>
</span>
</button>
</Popover>
<ProjectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
connections={connections}
/>
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</section>
);
}
+56 -1
View File
@@ -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<HTMLButtonElement>(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({
<div className="flex flex-col items-end gap-3">
<StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2">
<button
ref={exportButtonRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setExportOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={exportOpen}
title="Exportieren"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -211,6 +228,44 @@ export function ProjectHeader({
/>
</div>
<Popover
anchorRef={exportButtonRef}
open={exportOpen}
onClose={() => setExportOpen(false)}
width={250}
>
<a
href={`/api/projects/${project.id}/export`}
download
role="menuitem"
onClick={() => 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"
>
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Als JSON
<span className="mt-0.5 block text-[11px] text-slate-500">
Zum Wiedereinlesen, mit Notizen
</span>
</span>
</a>
<a
href={`/api/projects/${project.id}/export?format=md`}
download
role="menuitem"
onClick={() => 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"
>
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>
Als Markdown
<span className="mt-0.5 block text-[11px] text-slate-500">
Lesbar, inklusive Git-Stand
</span>
</span>
</a>
</Popover>
<ProjectDialog
open={editOpen}
onClose={() => setEditOpen(false)}
+37
View File
@@ -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 }) {
</dl>
</section>
{/* ── Sicherung ───────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-amber-500/12 text-amber-300">
<Database className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Sicherung
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-400">
Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten,
Verbindungen, Projekte und Notizen.
</p>
</div>
</div>
<a href="/api/admin/backup" download className="btn btn-ghost">
<Download className="h-4 w-4" />
Sicherung herunterladen
</a>
<p className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens
sind nur mit demselben <code>APP_SECRET</code> wieder lesbar, Passwoerter
liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine
byteweise Sicherung der Datenbank gibt es <code>scripts/backup.ps1</code>{" "}
beziehungsweise <code>scripts/backup.sh</code> mit <code>pg_dump</code>.
</span>
</p>
</section>
{/* ── Benutzer ────────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">