diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index b7907d7..1d54034 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -56,9 +56,14 @@ export default function TreeDetailPage() { return (
- - ← All trees - +
+ + ← All trees + + + Sources → + +
diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 5a0729e..cf5ac97 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -15,10 +15,11 @@ type Event = components["schemas"]["EventRead"]; type Relationship = components["schemas"]["RelationshipRead"]; type Qualifier = components["schemas"]["ParentChildQualifier"]; type RelCreate = components["schemas"]["RelationshipCreate"]; +type Source = components["schemas"]["SourceRead"]; +type Citation = components["schemas"]["CitationRead"]; +type CitationCreate = components["schemas"]["CitationCreate"]; -const fieldCls = - "h-10 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; - +const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"]; export default function PersonDetailPage() { @@ -31,6 +32,8 @@ export default function PersonDetailPage() { const [people, setPeople] = useState([]); const [events, setEvents] = useState([]); const [rels, setRels] = useState([]); + const [sources, setSources] = useState([]); + const [citations, setCitations] = useState([]); const [ready, setReady] = useState(false); const [evType, setEvType] = useState("birth"); @@ -40,6 +43,11 @@ export default function PersonDetailPage() { const [relOther, setRelOther] = useState(""); const [relQual, setRelQual] = useState("biological"); + // Inline citation form: which fact is being cited ("p" = person, `e:`). + const [citeFor, setCiteFor] = useState(null); + const [citeSource, setCiteSource] = useState(""); + const [citePage, setCitePage] = useState(""); + const load = useCallback(async () => { const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", { params: { path: { tree_id: treeId, person_id: personId } }, @@ -49,7 +57,7 @@ export default function PersonDetailPage() { return; } setPerson(p.data ?? null); - const [all, ev, rl] = await Promise.all([ + const [all, ev, rl, src, cit] = await Promise.all([ api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", { params: { path: { tree_id: treeId, person_id: personId } }, @@ -57,10 +65,14 @@ export default function PersonDetailPage() { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", { params: { path: { tree_id: treeId, person_id: personId } }, }), + api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }), + api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), ]); setPeople(all.data ?? []); setEvents(ev.data ?? []); setRels(rl.data ?? []); + setSources(src.data ?? []); + setCitations(cit.data ?? []); setReady(true); }, [router, treeId, personId]); @@ -72,12 +84,18 @@ export default function PersonDetailPage() { const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])); return (id: string) => m.get(id) ?? "Unknown"; }, [people]); + const sourceName = useMemo(() => { + const m = new Map(sources.map((s) => [s.id, s.title])); + return (id: string) => m.get(id) ?? "source"; + }, [sources]); const others = people.filter((p) => p.id !== personId); const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); const partners = rels.filter((r) => r.type === "partnership"); const siblings = rels.filter((r) => r.type === "sibling"); + const eventCites = (id: string) => citations.filter((c) => c.event_id === id); + const personCites = citations.filter((c) => c.person_id === personId); async function addEvent(e: React.FormEvent) { e.preventDefault(); @@ -91,7 +109,6 @@ export default function PersonDetailPage() { load(); } } - async function removeEvent(id: string) { await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", { params: { path: { tree_id: treeId, event_id: id } }, @@ -121,7 +138,6 @@ export default function PersonDetailPage() { load(); } } - async function removeRel(id: string) { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { params: { path: { tree_id: treeId, relationship_id: id } }, @@ -129,9 +145,100 @@ export default function PersonDetailPage() { load(); } + async function addCitation(target: Partial) { + if (!citeSource) return; + const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target }; + const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", { + params: { path: { tree_id: treeId } }, + body, + }); + if (!error) { + setCiteFor(null); + setCiteSource(""); + setCitePage(""); + load(); + } + } + async function removeCitation(id: string) { + await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", { + params: { path: { tree_id: treeId, citation_id: id } }, + }); + load(); + } + if (!ready) return

Loading…

; if (!person) return

Not found.

; + // Inline "cite" control: a badge with count, a toggle, and the picker form. + function citeControl(key: string, target: Partial, cites: Citation[]) { + return ( + + {cites.length > 0 && ( + sourceName(c.source_id)).join(", ")} + > + ✓ {cites.length} sourced + + )} + {citeFor === key ? ( +
{ + e.preventDefault(); + addCitation(target); + }} + className="inline-flex items-center gap-1" + > + + setCitePage(e.target.value)} + /> + + +
+ ) : sources.length === 0 ? ( + + + add a source first + + ) : ( + + )} +
+ ); + } + const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) => items.length > 0 && (
@@ -162,7 +269,10 @@ export default function PersonDetailPage() { ← Back to tree -

{person.primary_name ?? "Unnamed person"}

+
+

{person.primary_name ?? "Unnamed person"}

+ {citeControl("p", { person_id: personId }, personCites)} +
@@ -172,39 +282,32 @@ export default function PersonDetailPage() { {events.length === 0 ? (

No events yet.

) : ( -
    +
      {events.map((ev) => ( -
    • +
    • {ev.event_type} {ev.date_value ? ( — {ev.date_value} ) : null} - + + {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} + +
    • ))}
    )}
    - setEvType(e.target.value)} - /> - setEvDate(e.target.value)} - /> + setEvType(e.target.value)} /> + setEvDate(e.target.value)} />
    @@ -235,21 +338,13 @@ export default function PersonDetailPage() { ) : (
    Add - setRelKind(e.target.value as typeof relKind)}> - setRelOther(e.target.value)}> {others.map((p) => (