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:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user