Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client. 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:
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Source = components["schemas"]["SourceRead"];
|
||||
|
||||
export default function SourcesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [repository, setRepository] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/sources", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setSources(data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function add(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/sources", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { title, repository: repository || null, url: url || null },
|
||||
});
|
||||
if (!error) {
|
||||
setTitle("");
|
||||
setRepository("");
|
||||
setUrl("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/sources/{source_id}", {
|
||||
params: { path: { tree_id: treeId, source_id: id } },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||
← Back to tree
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Sources</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">New source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={add} className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
className="w-56"
|
||||
placeholder="Title (e.g. 1880 US Census)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="w-40"
|
||||
placeholder="Repository"
|
||||
value={repository}
|
||||
onChange={(e) => setRepository(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Add source</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No sources yet — add one above, then cite it on facts.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{sources.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Card>
|
||||
<CardContent className="flex items-start justify-between gap-3 p-4">
|
||||
<div>
|
||||
<div className="font-medium">{s.title}</div>
|
||||
<div className="text-sm text-[var(--muted)]">
|
||||
{[s.repository, s.url].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => remove(s.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user