Merge pull request 'Global Import menu entry + mobile drawer nav' (#22) from nav-global-import-mobile into main
build-frontend / build (push) Successful in 1m29s
build-frontend / build (push) Successful in 1m29s
This commit was merged in pull request #22.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
|
export default function ImportLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { GedcomImport } from "@/components/gedcom-import";
|
||||||
|
|
||||||
|
export default function ImportPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Import</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
Bring in a GEDCOM file — start a brand-new tree, or add to one you already have.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<GedcomImport />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,124 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
|
||||||
import type { components } from "@/lib/api/schema";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { GedcomImport } from "@/components/gedcom-import";
|
||||||
|
|
||||||
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
export default function TreeGedcomPage() {
|
||||||
type Preview = components["schemas"]["ImportPreview"];
|
|
||||||
type Dup = components["schemas"]["DuplicateMatch"];
|
|
||||||
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";
|
|
||||||
|
|
||||||
export default function GedcomPage() {
|
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const treeId = params.id;
|
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);
|
|
||||||
|
|
||||||
// Two-step dedupe flow (only when importing into an existing tree).
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
|
||||||
const [resolutions, setResolutions] = useState<Record<string, Action>>({});
|
|
||||||
|
|
||||||
function resetAll() {
|
|
||||||
setReport(null);
|
|
||||||
setImportedTreeId(null);
|
|
||||||
setPreview(null);
|
|
||||||
setFile(null);
|
|
||||||
setResolutions({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postImport(
|
|
||||||
tid: string,
|
|
||||||
f: File,
|
|
||||||
opts?: { resolutions?: string; defaultAction?: Action },
|
|
||||||
) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", f);
|
|
||||||
if (opts?.defaultAction) fd.append("default_action", opts.defaultAction);
|
|
||||||
if (opts?.resolutions) fd.append("resolutions", opts.resolutions);
|
|
||||||
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);
|
|
||||||
resetAll();
|
|
||||||
|
|
||||||
if (target === "new") {
|
|
||||||
// Fresh tree — nothing to dedupe against, import directly.
|
|
||||||
const { data } = await api.POST("/api/v1/trees", {
|
|
||||||
body: { name: newName.trim() || "Imported tree" },
|
|
||||||
});
|
|
||||||
if (data) await postImport(data.id, f);
|
|
||||||
setBusy(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing tree — preview for duplicates first.
|
|
||||||
setFile(f);
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", f);
|
|
||||||
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/preview`, {
|
|
||||||
method: "POST",
|
|
||||||
body: fd,
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const pv: Preview = await resp.json();
|
|
||||||
setPreview(pv);
|
|
||||||
// Default: high-confidence matches merge, lower ones come in as new.
|
|
||||||
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) 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(treeId, file, { resolutions: JSON.stringify(map) });
|
|
||||||
setPreview(null);
|
|
||||||
setFile(null);
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportGed() {
|
async function exportGed() {
|
||||||
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
@@ -131,196 +22,11 @@ export default function GedcomPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dups = preview?.potential_duplicates ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
||||||
|
|
||||||
<Card>
|
<GedcomImport fixedTreeId={treeId} />
|
||||||
<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");
|
|
||||||
resetAll();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
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");
|
|
||||||
resetAll();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Import into <strong>this tree</strong> (checks for duplicates)
|
|
||||||
</label>
|
|
||||||
{target === "this" && !preview && (
|
|
||||||
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
|
||||||
We'll scan the file and flag anyone who looks like a person already in this
|
|
||||||
tree, so you can merge, skip, or overwrite before anything is saved.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept=".ged,.gedcom,text/plain"
|
|
||||||
onChange={onFile}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
{!preview && (
|
|
||||||
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
|
||||||
{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}
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
defaultValue=""
|
|
||||||
>
|
|
||||||
<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={resetAll} 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 imported tree →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import Link from "next/link";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
|
||||||
|
|
||||||
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <AppShell>{children}</AppShell>;
|
||||||
<div className="flex min-h-screen">
|
|
||||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
|
|
||||||
<AppSidebar />
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
{/* Compact bar for small screens (full sidebar is md+). */}
|
|
||||||
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
|
|
||||||
<Link href="/" aria-label="Provenance — home">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
|
||||||
</Link>
|
|
||||||
<Link href="/trees" className="text-sm text-bronze">
|
|
||||||
Trees
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App chrome: a fixed sidebar on md+, and on small screens a top bar with a
|
||||||
|
* hamburger that opens the same sidebar as a slide-in drawer.
|
||||||
|
*/
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
|
||||||
|
<AppSidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* Mobile top bar */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Open menu"
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<Link href="/" aria-label="Provenance — home">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 md:hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<aside className="absolute left-0 top-0 h-full w-72 max-w-[85%] border-r border-[var(--border)] bg-[var(--surface)] shadow-xl">
|
||||||
|
<AppSidebar onNavigate={() => setOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||||
@@ -35,6 +35,7 @@ export function AppSidebar() {
|
|||||||
}, [treeId]);
|
}, [treeId]);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
onNavigate?.();
|
||||||
await api.POST("/api/v1/auth/logout");
|
await api.POST("/api/v1/auth/logout");
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ export function AppSidebar() {
|
|||||||
}) => (
|
}) => (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
active
|
active
|
||||||
@@ -72,6 +74,7 @@ export function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||||
|
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
|
||||||
|
|
||||||
{treeId && (
|
{treeId && (
|
||||||
<div className="mt-5 flex flex-col gap-1">
|
<div className="mt-5 flex flex-col gap-1">
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user