Preserve focused person across tree/people/detail navigation
The Tree view, People (Family) view, and person detail page each tracked the "current person" independently, so moving between them reset you to the home person. The detail page's "← Back to tree" link also pointed at the People view (not the Tree) and carried no person, so it always landed on the default person. Make the focused person a URL-encoded concept that travels across views: - Tree and People views read ?focus=<id> on load and mirror the focused person back into the URL via router.replace (no history spam), so leaving and returning keeps you centered where you were. Bookmarks/shared links also resolve to the right person. - "Open person" links carry ?from=tree | ?from=people. - The detail page's back link is now origin-aware: "← Back to Tree" → /tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in place instead of to the home person. - Add a "View in tree →" link on the detail page — the previously missing direct jump from a person to the tree re-rooted on them. - person→person relationship links (and create-relative redirect) pass `from` through so click-chains keep their anchor. Also gitignore *.tsbuildinfo (Next build artifact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -3,4 +3,5 @@
|
|||||||
/out
|
/out
|
||||||
/build
|
/build
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
@@ -26,7 +26,11 @@ type AddKind = "parent" | "child" | "partner";
|
|||||||
export default function FamilyViewPage() {
|
export default function FamilyViewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
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 [people, setPeople] = useState<Person[]>([]);
|
||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
@@ -58,10 +62,13 @@ export default function FamilyViewPage() {
|
|||||||
const ppl = p.data ?? [];
|
const ppl = p.data ?? [];
|
||||||
const home = t.data?.home_person_id ?? null;
|
const home = t.data?.home_person_id ?? null;
|
||||||
const homeId = home && ppl.some((x) => x.id === home) ? home : 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);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -69,6 +76,16 @@ export default function FamilyViewPage() {
|
|||||||
load();
|
load();
|
||||||
}, [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.
|
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = search.trim();
|
const q = search.trim();
|
||||||
@@ -363,7 +380,7 @@ export default function FamilyViewPage() {
|
|||||||
+ Add person
|
+ Add person
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href={`/trees/${treeId}/persons/${focus.id}`}
|
href={`/trees/${treeId}/persons/${focus.id}?from=people`}
|
||||||
className="text-sm text-bronze hover:underline"
|
className="text-sm text-bronze hover:underline"
|
||||||
>
|
>
|
||||||
Open {focus.primary_name ?? "person"} →
|
Open {focus.primary_name ?? "person"} →
|
||||||
@@ -432,7 +449,7 @@ export default function FamilyViewPage() {
|
|||||||
<div key={p.id} className="flex items-center gap-1">
|
<div key={p.id} className="flex items-center gap-1">
|
||||||
<PersonBox id={p.id} muted />
|
<PersonBox id={p.id} muted />
|
||||||
<Link
|
<Link
|
||||||
href={`/trees/${treeId}/persons/${p.id}`}
|
href={`/trees/${treeId}/persons/${p.id}?from=people`}
|
||||||
className="text-xs text-bronze hover:underline"
|
className="text-xs text-bronze hover:underline"
|
||||||
>
|
>
|
||||||
open
|
open
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
@@ -104,8 +104,19 @@ function parseDateValue(v: string | null | undefined) {
|
|||||||
export default function PersonDetailPage() {
|
export default function PersonDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string; personId: string }>();
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
const personId = params.personId;
|
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 [person, setPerson] = useState<Person | null>(null);
|
||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
@@ -385,7 +396,7 @@ export default function PersonDetailPage() {
|
|||||||
});
|
});
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
await linkRelative(data.id);
|
await linkRelative(data.id);
|
||||||
router.push(`/trees/${treeId}/persons/${data.id}`);
|
router.push(personHref(data.id));
|
||||||
}
|
}
|
||||||
async function removeRel(id: string) {
|
async function removeRel(id: string) {
|
||||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
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">
|
<ul className="mt-1 space-y-1">
|
||||||
{items.map((r) => (
|
{items.map((r) => (
|
||||||
<li key={r.id} className="flex items-center justify-between text-sm">
|
<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))}
|
{nameOf(otherId(r))}
|
||||||
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -643,9 +654,17 @@ export default function PersonDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
<div className="flex items-center justify-between gap-3">
|
||||||
← Back to tree
|
<Link href={backHref} className="text-sm text-[var(--muted)] hover:underline">
|
||||||
</Link>
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/tree?focus=${personId}`}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
View in tree →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editingPerson ? (
|
{editingPerson ? (
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import "./chart.css";
|
import "./chart.css";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
@@ -27,7 +27,11 @@ function splitName(name: string | null | undefined): [string, string] {
|
|||||||
export default function TreePage() {
|
export default function TreePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
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);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const chartRef = useRef<any>(null);
|
const chartRef = useRef<any>(null);
|
||||||
@@ -63,8 +67,12 @@ export default function TreePage() {
|
|||||||
setPeople(ppl);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
// Open on the tree's default/home person when set, else the first person.
|
// Honor an explicit ?focus first (came from a person page / a shared
|
||||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
// 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");
|
setStatus(ppl.length ? "ready" : "empty");
|
||||||
})().catch(() => !cancelled && setStatus("error"));
|
})().catch(() => !cancelled && setStatus("error"));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -221,6 +229,16 @@ export default function TreePage() {
|
|||||||
[mode],
|
[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 matches = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
@@ -279,7 +297,7 @@ export default function TreePage() {
|
|||||||
</div>
|
</div>
|
||||||
{focusId && (
|
{focusId && (
|
||||||
<Link
|
<Link
|
||||||
href={`/trees/${treeId}/persons/${focusId}`}
|
href={`/trees/${treeId}/persons/${focusId}?from=tree`}
|
||||||
className="text-sm text-bronze hover:underline"
|
className="text-sm text-bronze hover:underline"
|
||||||
>
|
>
|
||||||
Open {nameOf(focusId)} →
|
Open {nameOf(focusId)} →
|
||||||
|
|||||||
Reference in New Issue
Block a user