Merge pull request 'Preserve focused person across tree/people/detail navigation' (#35) from improve-tree-people-navigation into main
build-frontend / build (push) Successful in 1m27s

This commit was merged in pull request #35.
This commit is contained in:
2026-06-08 15:07:10 -04:00
4 changed files with 70 additions and 15 deletions
+1
View File
@@ -3,4 +3,5 @@
/out
/build
next-env.d.ts
*.tsbuildinfo
.env*.local
+22 -5
View File
@@ -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<string | null>(searchParams.get("focus"));
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
@@ -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
</Button>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
href={`/trees/${treeId}/persons/${focus.id}?from=people`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
@@ -432,7 +449,7 @@ export default function FamilyViewPage() {
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}`}
href={`/trees/${treeId}/persons/${p.id}?from=people`}
className="text-xs text-bronze hover:underline"
>
open
@@ -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<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]);
@@ -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() {
<ul className="mt-1 space-y-1">
{items.map((r) => (
<li key={r.id} className="flex items-center justify-between text-sm">
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
<Link href={personHref(otherId(r))} className="hover:underline">
{nameOf(otherId(r))}
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
</Link>
@@ -643,9 +654,17 @@ export default function PersonDetailPage() {
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
<div className="flex items-center justify-between gap-3">
<Link href={backHref} className="text-sm text-[var(--muted)] hover:underline">
{backLabel}
</Link>
<Link
href={`/trees/${treeId}/tree?focus=${personId}`}
className="text-sm text-bronze hover:underline"
>
View in tree
</Link>
</div>
{editingPerson ? (
<form
+22 -4
View File
@@ -4,7 +4,7 @@
import "./chart.css";
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";
@@ -27,7 +27,11 @@ function splitName(name: string | null | undefined): [string, string] {
export default function TreePage() {
const router = useRouter();
const params = useParams<{ id: string }>();
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<string | null>(searchParams.get("focus"));
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(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() {
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}`}
href={`/trees/${treeId}/persons/${focusId}?from=tree`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}