diff --git a/frontend/.gitignore b/frontend/.gitignore index 06c369c..0684cd7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -3,4 +3,5 @@ /out /build next-env.d.ts +*.tsbuildinfo .env*.local diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index d5ce191..ed203fd 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/lib/api/client"; import type { components } from "@/lib/api/schema"; @@ -26,7 +26,11 @@ type AddKind = "parent" | "child" | "partner"; export default function FamilyViewPage() { const router = useRouter(); const params = useParams<{ id: string }>(); + const searchParams = useSearchParams(); const treeId = params.id; + // ?focus=… lets another view (or a person page) hand us who to center on. + // Read once at mount so the focus→URL sync below doesn't trigger a refetch. + const initialFocus = useRef(searchParams.get("focus")); const [people, setPeople] = useState([]); const [rels, setRels] = useState([]); @@ -58,10 +62,13 @@ export default function FamilyViewPage() { const ppl = p.data ?? []; const home = t.data?.home_person_id ?? null; const homeId = home && ppl.some((x) => x.id === home) ? home : null; + const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current) + ? initialFocus.current + : null; setPeople(ppl); setRels(r.data ?? []); setEvents(e.data ?? []); - setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null); + setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null); setReady(true); }, [router, treeId]); @@ -69,6 +76,16 @@ export default function FamilyViewPage() { load(); }, [load]); + // Keep the focused person in the URL (?focus=…) so leaving and returning — + // e.g. opening a person then coming back — lands on the same person rather + // than resetting to the home person. `replace` keeps history clean. + useEffect(() => { + if (!focusId || searchParams.get("focus") === focusId) return; + const sp = new URLSearchParams(searchParams.toString()); + sp.set("focus", focusId); + router.replace(`/trees/${treeId}?${sp.toString()}`, { scroll: false }); + }, [focusId, searchParams, router, treeId]); + // Debounced server-side fuzzy search (pg_trgm) across the whole tree. useEffect(() => { const q = search.trim(); @@ -363,7 +380,7 @@ export default function FamilyViewPage() { + Add person Open {focus.primary_name ?? "person"} → @@ -432,7 +449,7 @@ export default function FamilyViewPage() {
open diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 5e298c3..2278492 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/lib/api/client"; @@ -104,8 +104,19 @@ function parseDateValue(v: string | null | undefined) { export default function PersonDetailPage() { const router = useRouter(); const params = useParams<{ id: string; personId: string }>(); + const searchParams = useSearchParams(); const treeId = params.id; const personId = params.personId; + // Where we were opened from, so "back" returns there (centered on this + // person) instead of always dumping onto the People view's home person. + const from = searchParams.get("from") === "people" ? "people" : "tree"; + const backHref = + from === "people" + ? `/trees/${treeId}?focus=${personId}` + : `/trees/${treeId}/tree?focus=${personId}`; + const backLabel = from === "people" ? "← Back to People" : "← Back to Tree"; + // Carry the origin through person→person links so the chain keeps its anchor. + const personHref = (id: string) => `/trees/${treeId}/persons/${id}?from=${from}`; const [person, setPerson] = useState(null); const [people, setPeople] = useState([]); @@ -385,7 +396,7 @@ export default function PersonDetailPage() { }); if (!data) return; await linkRelative(data.id); - router.push(`/trees/${treeId}/persons/${data.id}`); + router.push(personHref(data.id)); } async function removeRel(id: string) { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { @@ -624,7 +635,7 @@ export default function PersonDetailPage() {
    {items.map((r) => (
  • - + {nameOf(otherId(r))} {r.qualifier ? · {r.qualifier} : null} @@ -643,9 +654,17 @@ export default function PersonDetailPage() { return (
    - - ← Back to tree - +
    + + {backLabel} + + + View in tree → + +
    {editingPerson ? (
    (); + const searchParams = useSearchParams(); const treeId = params.id; + // The focused person can arrive in the URL (?focus=…) — e.g. coming back from + // a person page. Captured once at mount so syncing focus→URL doesn't refetch. + const initialFocus = useRef(searchParams.get("focus")); const containerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const chartRef = useRef(null); @@ -63,8 +67,12 @@ export default function TreePage() { setPeople(ppl); setRels(r.data ?? []); setEvents(e.data ?? []); - // Open on the tree's default/home person when set, else the first person. - setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null); + // Honor an explicit ?focus first (came from a person page / a shared + // link), then the tree's default/home person, then the first person. + const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current) + ? initialFocus.current + : null; + setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null); setStatus(ppl.length ? "ready" : "empty"); })().catch(() => !cancelled && setStatus("error")); return () => { @@ -221,6 +229,16 @@ export default function TreePage() { [mode], ); + // Mirror the focused person into the URL (?focus=…) so navigating away and + // back — or sharing the link — keeps the tree centered where you left it. + // `replace` (not push) so each recenter doesn't pile up in browser history. + useEffect(() => { + if (!focusId || searchParams.get("focus") === focusId) return; + const sp = new URLSearchParams(searchParams.toString()); + sp.set("focus", focusId); + router.replace(`/trees/${treeId}/tree?${sp.toString()}`, { scroll: false }); + }, [focusId, searchParams, router, treeId]); + const matches = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; @@ -279,7 +297,7 @@ export default function TreePage() {
    {focusId && ( Open {nameOf(focusId)} →