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
build-frontend / build (push) Successful in 1m27s
This commit was merged in pull request #35.
This commit is contained in:
@@ -3,4 +3,5 @@
|
||||
/out
|
||||
/build
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
.env*.local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)} →
|
||||
|
||||
Reference in New Issue
Block a user