From 1164841950ff64bb0ef4f9b4c28d2d3763216f3a Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 10:40:01 -0400 Subject: [PATCH] Global Import in the menu; mobile drawer nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 with a fixed destination and keeps Export. - Extract the sidebar chrome into 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) --- frontend/app/import/layout.tsx | 5 + frontend/app/import/page.tsx | 15 ++ frontend/app/trees/[id]/gedcom/page.tsx | 300 +-------------------- frontend/app/trees/layout.tsx | 27 +- frontend/components/app-shell.tsx | 56 ++++ frontend/components/app-sidebar.tsx | 5 +- frontend/components/gedcom-import.tsx | 331 ++++++++++++++++++++++++ 7 files changed, 416 insertions(+), 323 deletions(-) create mode 100644 frontend/app/import/layout.tsx create mode 100644 frontend/app/import/page.tsx create mode 100644 frontend/components/app-shell.tsx create mode 100644 frontend/components/gedcom-import.tsx diff --git a/frontend/app/import/layout.tsx b/frontend/app/import/layout.tsx new file mode 100644 index 0000000..92b52a0 --- /dev/null +++ b/frontend/app/import/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from "@/components/app-shell"; + +export default function ImportLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/app/import/page.tsx b/frontend/app/import/page.tsx new file mode 100644 index 0000000..09530fb --- /dev/null +++ b/frontend/app/import/page.tsx @@ -0,0 +1,15 @@ +import { GedcomImport } from "@/components/gedcom-import"; + +export default function ImportPage() { + return ( +
+
+

Import

+

+ Bring in a GEDCOM file — start a brand-new tree, or add to one you already have. +

+
+ +
+ ); +} diff --git a/frontend/app/trees/[id]/gedcom/page.tsx b/frontend/app/trees/[id]/gedcom/page.tsx index 829996c..5d42e8c 100644 --- a/frontend/app/trees/[id]/gedcom/page.tsx +++ b/frontend/app/trees/[id]/gedcom/page.tsx @@ -1,124 +1,15 @@ "use client"; -import Link from "next/link"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { GedcomImport } from "@/components/gedcom-import"; -type Report = { counts: Record; unmapped_tags: string[] }; -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() { +export default function TreeGedcomPage() { 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(null); - const [importedTreeId, setImportedTreeId] = useState(null); - const fileRef = useRef(null); - - // Two-step dedupe flow (only when importing into an existing tree). - const [file, setFile] = useState(null); - const [preview, setPreview] = useState(null); - const [resolutions, setResolutions] = useState>({}); - - 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) { - 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 = {}; - 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 = {}; - 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() { const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" }); if (!resp.ok) return; @@ -131,196 +22,11 @@ export default function GedcomPage() { URL.revokeObjectURL(url); } - const dups = preview?.potential_duplicates ?? []; - return (

Import & export GEDCOM

- - - Import a GEDCOM file - - - - {target === "new" && ( - setNewName(e.target.value)} - /> - )} - - {target === "this" && !preview && ( -

- 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. -

- )} - - - {!preview && ( - - )} - - {/* Duplicate-resolution step */} - {preview && ( -
-
- {Object.entries(preview.counts).map(([k, v]) => ( - - {v} {k} - - ))} -
- - {dups.length === 0 ? ( -

- No likely duplicates found — everyone will be imported as new. -

- ) : ( -
-
-

- {dups.length} possible duplicate{dups.length === 1 ? "" : "s"} -

- -
-
    - {dups.map((d: Dup) => ( -
  • -
    - {d.incoming_name} - {d.incoming_birth_year && ( - b. {d.incoming_birth_year} - )} - - {d.existing_name} - {d.existing_birth_year && ( - b. {d.existing_birth_year} - )} - - {d.score} - -
    - -
  • - ))} -
-
- )} - -
- - -
-
- )} - - {report && ( -
-
Import complete
-
- {Object.entries(report.counts).map(([k, v]) => ( - - {v} {k} - - ))} -
- {report.unmapped_tags.length > 0 && ( -
- Unmapped tags (skipped): {report.unmapped_tags.join(", ")} -
- )} - {importedTreeId && ( - - Open the imported tree → - - )} -
- )} -
-
+ diff --git a/frontend/app/trees/layout.tsx b/frontend/app/trees/layout.tsx index c1956f1..f2a9081 100644 --- a/frontend/app/trees/layout.tsx +++ b/frontend/app/trees/layout.tsx @@ -1,28 +1,5 @@ -import Link from "next/link"; - -import { AppSidebar } from "@/components/app-sidebar"; +import { AppShell } from "@/components/app-shell"; export default function TreesLayout({ children }: { children: React.ReactNode }) { - return ( -
- - -
- {/* Compact bar for small screens (full sidebar is md+). */} -
- - {/* eslint-disable-next-line @next/next/no-img-element */} - Provenance - - - Trees - -
- -
{children}
-
-
- ); + return {children}; } diff --git a/frontend/components/app-shell.tsx b/frontend/components/app-shell.tsx new file mode 100644 index 0000000..b803d72 --- /dev/null +++ b/frontend/components/app-shell.tsx @@ -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 ( +
+ + +
+ {/* Mobile top bar */} +
+ + + {/* eslint-disable-next-line @next/next/no-img-element */} + Provenance + +
+ + {/* Mobile drawer */} + {open && ( +
+
setOpen(false)} + aria-hidden + /> + +
+ )} + +
{children}
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 4351f84..fe358e0 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -17,7 +17,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api/client"; import { cn } from "@/lib/utils"; -export function AppSidebar() { +export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); const router = useRouter(); const segs = pathname.split("/").filter(Boolean); // ["trees", "", ...] @@ -35,6 +35,7 @@ export function AppSidebar() { }, [treeId]); async function logout() { + onNavigate?.(); await api.POST("/api/v1/auth/logout"); router.push("/login"); } @@ -52,6 +53,7 @@ export function AppSidebar() { }) => ( + {treeId && (
diff --git a/frontend/components/gedcom-import.tsx b/frontend/components/gedcom-import.tsx new file mode 100644 index 0000000..ee7f7e1 --- /dev/null +++ b/frontend/components/gedcom-import.tsx @@ -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; 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([]); + 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(null); + const [importedTreeId, setImportedTreeId] = useState(null); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [resolutions, setResolutions] = useState>({}); + const fileRef = useRef(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) { + 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 = {}; + 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 = {}; + 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 ( + + + Import a GEDCOM file + + + {!preview && ( + <> + + {mode === "new" && ( + setNewName(e.target.value)} + /> + )} + + + {mode === "existing" && ( + + )} + + + + + )} + + {/* Duplicate-resolution step */} + {preview && ( +
+
+ {Object.entries(preview.counts).map(([k, v]) => ( + + {v} {k} + + ))} +
+ + {dups.length === 0 ? ( +

+ No likely duplicates found — everyone will be imported as new. +

+ ) : ( +
+
+

+ {dups.length} possible duplicate{dups.length === 1 ? "" : "s"} +

+ +
+
    + {dups.map((d: Dup) => ( +
  • +
    + {d.incoming_name} + {d.incoming_birth_year && ( + b. {d.incoming_birth_year} + )} + + {d.existing_name} + {d.existing_birth_year && ( + b. {d.existing_birth_year} + )} + + {d.score} + +
    + +
  • + ))} +
+
+ )} + +
+ + +
+
+ )} + + {report && ( +
+
Import complete
+
+ {Object.entries(report.counts).map(([k, v]) => ( + + {v} {k} + + ))} +
+ {report.unmapped_tags.length > 0 && ( +
+ Unmapped tags (skipped): {report.unmapped_tags.join(", ")} +
+ )} + {importedTreeId && ( + + Open the tree → + + )} +
+ )} +
+
+ ); +}