631d050540
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
164 lines
5.4 KiB
TypeScript
164 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useParams } from "next/navigation";
|
|
import { useRef, useState } from "react";
|
|
|
|
import { api } from "@/lib/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
|
|
|
export default function GedcomPage() {
|
|
const params = useParams<{ id: string }>();
|
|
const treeId = params.id;
|
|
|
|
const [target, setTarget] = useState<"new" | "this">("new");
|
|
const [newName, setNewName] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [report, setReport] = useState<Report | null>(null);
|
|
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setBusy(true);
|
|
setReport(null);
|
|
setImportedTreeId(null);
|
|
|
|
let tid = treeId;
|
|
if (target === "new") {
|
|
const { data } = await api.POST("/api/v1/trees", {
|
|
body: { name: newName.trim() || "Imported tree" },
|
|
});
|
|
if (!data) {
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
tid = data.id;
|
|
setImportedTreeId(tid);
|
|
} else {
|
|
setImportedTreeId(treeId);
|
|
}
|
|
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
|
|
method: "POST",
|
|
body: fd,
|
|
credentials: "include",
|
|
});
|
|
if (resp.ok) setReport(await resp.json());
|
|
setBusy(false);
|
|
if (fileRef.current) fileRef.current.value = "";
|
|
}
|
|
|
|
async function exportGed() {
|
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
|
|
credentials: "include",
|
|
});
|
|
if (!resp.ok) return;
|
|
const blob = await resp.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "tree.ged";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="target"
|
|
checked={target === "new"}
|
|
onChange={() => setTarget("new")}
|
|
/>
|
|
Import into a <strong>new tree</strong> (recommended)
|
|
</label>
|
|
{target === "new" && (
|
|
<Input
|
|
className="max-w-xs"
|
|
placeholder="New tree name"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
/>
|
|
)}
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="target"
|
|
checked={target === "this"}
|
|
onChange={() => setTarget("this")}
|
|
/>
|
|
Import into <strong>this tree</strong> (appends)
|
|
</label>
|
|
{target === "this" && (
|
|
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
|
Importing appends everyone in the file as new records — it does not merge with
|
|
people already in this tree, so duplicates are possible.
|
|
</p>
|
|
)}
|
|
|
|
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
|
|
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
|
{busy ? "Importing…" : "Choose GEDCOM file"}
|
|
</Button>
|
|
|
|
{report && (
|
|
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
|
<div className="font-medium">Import complete</div>
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
|
{Object.entries(report.counts).map(([k, v]) => (
|
|
<span key={k}>
|
|
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{report.unmapped_tags.length > 0 && (
|
|
<div className="text-xs text-[var(--muted)]">
|
|
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
|
</div>
|
|
)}
|
|
{importedTreeId && (
|
|
<Link
|
|
href={`/trees/${importedTreeId}`}
|
|
className="inline-block text-sm text-bronze hover:underline"
|
|
>
|
|
Open the imported tree →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Export this tree</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-[var(--muted)]">
|
|
Download this tree as a GEDCOM file — people, relationships, events, and sources.
|
|
</p>
|
|
<Button variant="outline" onClick={exportGed}>
|
|
Download .ged
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|