Files
justin 1164841950 Global Import in the menu; mobile drawer nav
- Add a top-level "Import" entry to the sidebar and a global /import page, so
  you can start a tree from a GEDCOM without first creating an empty one. The
  import flow now picks its destination (new tree, or an existing one) — the
  tree-scoped page reuses the same <GedcomImport> with a fixed destination and
  keeps Export.
- Extract the sidebar chrome into <AppShell> and give small screens a working
  menu: a hamburger opens the full sidebar as a slide-in drawer (it was just a
  logo + "Trees" link before). Used by both /trees and /import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:40:01 -04:00

332 lines
12 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
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[] };
type Preview = components["schemas"]["ImportPreview"];
type Dup = components["schemas"]["DuplicateMatch"];
type Tree = components["schemas"]["TreeRead"];
type Action = "new" | "skip" | "merge" | "overwrite";
const ACTIONS: { value: Action; label: string }[] = [
{ value: "new", label: "Import as new" },
{ value: "merge", label: "Merge into existing" },
{ value: "skip", label: "Skip (use existing)" },
{ value: "overwrite", label: "Overwrite existing" },
];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
/**
* GEDCOM import with a selectable destination (a new tree, or an existing one).
* Importing into an existing tree previews likely duplicates so each can be
* resolved (new / skip / merge / overwrite) before anything is written.
*
* @param fixedTreeId When set, the destination defaults to that tree (the
* tree-scoped Import page). When omitted (the global Import page), the user
* picks a destination.
*/
export function GedcomImport({ fixedTreeId }: { fixedTreeId?: string }) {
const [trees, setTrees] = useState<Tree[]>([]);
const [mode, setMode] = useState<"new" | "existing">(fixedTreeId ? "existing" : "new");
const [chosenTreeId, setChosenTreeId] = useState(fixedTreeId ?? "");
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [resolutions, setResolutions] = useState<Record<string, Action>>({});
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.GET("/api/v1/trees").then((r) => setTrees(r.data ?? []));
}, []);
function resetRun() {
setReport(null);
setImportedTreeId(null);
setPreview(null);
setFile(null);
setResolutions({});
}
async function postImport(tid: string, f: File, resolutionsJson?: string) {
const fd = new FormData();
fd.append("file", f);
if (resolutionsJson) fd.append("resolutions", resolutionsJson);
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
setReport(await resp.json());
setImportedTreeId(tid);
}
}
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (fileRef.current) fileRef.current.value = "";
if (!f) return;
setBusy(true);
resetRun();
if (mode === "new") {
const { data } = await api.POST("/api/v1/trees", {
body: { name: newName.trim() || "Imported tree" },
});
if (data) await postImport(data.id, f);
setBusy(false);
return;
}
if (!chosenTreeId) {
setBusy(false);
return;
}
// Existing tree — preview duplicates first.
setFile(f);
const fd = new FormData();
fd.append("file", f);
const resp = await fetch(`/api/v1/trees/${chosenTreeId}/gedcom/preview`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
const pv: Preview = await resp.json();
setPreview(pv);
const init: Record<string, Action> = {};
for (const d of pv.potential_duplicates) init[d.xref] = d.score === "high" ? "merge" : "new";
setResolutions(init);
}
setBusy(false);
}
async function runImport() {
if (!file || !chosenTreeId) return;
setBusy(true);
const map: Record<string, { action: Action; target_id: string }> = {};
for (const d of preview?.potential_duplicates ?? []) {
const action = resolutions[d.xref] ?? "new";
if (action !== "new") map[d.xref] = { action, target_id: d.existing_person_id };
}
await postImport(chosenTreeId, file, JSON.stringify(map));
setPreview(null);
setFile(null);
setBusy(false);
}
const dups = preview?.potential_duplicates ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!preview && (
<>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={mode === "new"}
onChange={() => {
setMode("new");
resetRun();
}}
/>
Import into a <strong>new tree</strong>
</label>
{mode === "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"
checked={mode === "existing"}
onChange={() => {
setMode("existing");
resetRun();
}}
/>
Import into an <strong>existing tree</strong> (checks for duplicates)
</label>
{mode === "existing" && (
<select
className={`${fieldCls} max-w-xs`}
value={chosenTreeId}
onChange={(e) => setChosenTreeId(e.target.value)}
>
<option value=""> choose a tree </option>
{trees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
<input
ref={fileRef}
type="file"
accept=".ged,.gedcom,text/plain"
onChange={onFile}
className="hidden"
/>
<Button
onClick={() => fileRef.current?.click()}
disabled={busy || (mode === "existing" && !chosenTreeId)}
>
{busy ? "Working…" : "Choose GEDCOM file"}
</Button>
</>
)}
{/* Duplicate-resolution step */}
{preview && (
<div className="space-y-4">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
{Object.entries(preview.counts).map(([k, v]) => (
<span key={k}>
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
</span>
))}
</div>
{dups.length === 0 ? (
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm">
No likely duplicates found everyone will be imported as new.
</p>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
{dups.length} possible duplicate{dups.length === 1 ? "" : "s"}
</h3>
<label className="flex items-center gap-2 text-xs text-[var(--muted)]">
Set all to
<select
className={fieldCls}
defaultValue=""
onChange={(e) => {
const a = e.target.value as Action;
const all: Record<string, Action> = {};
for (const d of dups) all[d.xref] = a;
setResolutions(all);
}}
>
<option value="" disabled>
choose
</option>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</label>
</div>
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
{dups.map((d: Dup) => (
<li
key={d.xref}
className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div className="min-w-0">
<span className="font-medium">{d.incoming_name}</span>
{d.incoming_birth_year && (
<span className="text-[var(--muted)]"> b. {d.incoming_birth_year}</span>
)}
<span className="text-[var(--muted)]"> </span>
<span>{d.existing_name}</span>
{d.existing_birth_year && (
<span className="text-[var(--muted)]"> b. {d.existing_birth_year}</span>
)}
<span
className={`ml-2 rounded px-1.5 py-0.5 text-xs ${
d.score === "high"
? "bg-bronze/15 text-bronze"
: "bg-[var(--border)]/50 text-[var(--muted)]"
}`}
>
{d.score}
</span>
</div>
<select
className={fieldCls}
value={resolutions[d.xref] ?? "new"}
onChange={(e) =>
setResolutions((r) => ({ ...r, [d.xref]: e.target.value as Action }))
}
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<Button onClick={runImport} disabled={busy}>
{busy ? "Importing…" : "Run import"}
</Button>
<Button variant="ghost" onClick={resetRun} disabled={busy}>
Cancel
</Button>
</div>
</div>
)}
{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 tree
</Link>
)}
</div>
)}
</CardContent>
</Card>
);
}