diff --git a/backend/src/routes/persons.js b/backend/src/routes/persons.js index 8cad52a..ec0ea6e 100644 --- a/backend/src/routes/persons.js +++ b/backend/src/routes/persons.js @@ -75,4 +75,77 @@ router.delete('/:id', authRequired, requireRole('admin'), async (req, res) => { res.status(204).end(); }); +async function findWikipediaPhoto(firstName, lastName, birthYear) { + const langs = ['ru', 'en']; + for (const lang of langs) { + try { + const encoded = encodeURIComponent(`${firstName} ${lastName}`); + const searchRes = await fetch( + `https://${lang}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encoded}&srlimit=3&format=json&origin=*`, + { headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const searchData = await searchRes.json(); + const pages = searchData?.query?.search || []; + if (!pages.length) continue; + const pageTitle = encodeURIComponent(pages[0].title); + const imgRes = await fetch( + `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${pageTitle}&prop=pageimages&pithumbsize=400&format=json&origin=*`, + { headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const imgData = await imgRes.json(); + const page = Object.values(imgData?.query?.pages || {})[0]; + if (!page?.thumbnail?.source) continue; + return { + photo_url: page.thumbnail.source, + source_url: `https://${lang}.wikipedia.org/wiki/${pageTitle}`, + source_name: `Wikipedia (${lang})`, + }; + } catch { continue; } + } + return null; +} + +async function findWikidataPhoto(firstName, lastName) { + try { + const q = encodeURIComponent(`${firstName} ${lastName}`); + const res = await fetch( + `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${q}&language=ru&type=item&limit=3&format=json&origin=*`, + { headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const data = await res.json(); + const entity = data?.search?.[0]; + if (!entity) return null; + const propRes = await fetch( + `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${entity.id}&property=P18&format=json&origin=*`, + { headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const propData = await propRes.json(); + const claims = propData?.claims?.P18; + if (!claims?.length) return null; + const filename = claims[0].mainsnak?.datavalue?.value; + if (!filename) return null; + const encoded = encodeURIComponent(filename.replace(/ /g, '_')); + return { + photo_url: `https://commons.wikimedia.org/wiki/Special:FilePath/${encoded}?width=400`, + source_url: `https://www.wikidata.org/wiki/${entity.id}`, + source_name: 'Wikidata', + }; + } catch { return null; } +} + +router.post('/:id/scan-photo', authRequired, requireRole('admin', 'editor'), async (req, res) => { + const { id } = req.params; + const person = (await query('SELECT * FROM persons WHERE id=$1', [id])).rows[0]; + if (!person) return res.status(404).json({ error: 'Персона не найдена' }); + const firstName = req.body.first_name || person.first_name; + const lastName = req.body.last_name || person.last_name || ''; + const birthYear = (req.body.birth_date || person.birth_date)?.toString().slice(0, 4); + let result = await findWikipediaPhoto(firstName, lastName, birthYear); + if (!result) result = await findWikidataPhoto(firstName, lastName); + if (!result) return res.json({ found: false }); + await query('UPDATE persons SET photo_url=$1, resource_url=$2 WHERE id=$3', [result.photo_url, result.source_url, id]); + await logChange(req.user.id, id, 'scan-photo', result); + res.json({ found: true, photo_url: result.photo_url, source_url: result.source_url, source_name: result.source_name }); +}); + export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15b840f..7b1c888 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ export default function App() { const [people, setPeople] = useState([]); const [viz, setViz] = useState(DEFAULT_VIZ); const [selected, setSelected] = useState(null); + const [rootId, setRootId] = useState(null); const [dark, setDark] = useState(matchMedia('(prefers-color-scheme: dark)').matches); const [q, setQ] = useState(''); @@ -73,7 +74,7 @@ export default function App() {
- +
{selected && setSelected(null)} onNavigate={setSelected} />} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cc29ef0..a9447ee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,4 +34,6 @@ export const api = { history: () => req('/relations/history'), exportJson: () => req('/io/export/json'), importJson: (data: unknown) => req('/io/import/json', { method: 'POST', body: JSON.stringify(data) }), + scanPhoto: (id: string, data?: { first_name?: string; last_name?: string; birth_date?: string }) => + req(`/persons/${id}/scan-photo`, { method: 'POST', body: JSON.stringify(data || {}) }), }; diff --git a/frontend/src/components/FamilyTree.tsx b/frontend/src/components/FamilyTree.tsx index 1efcc4f..e85eefe 100644 --- a/frontend/src/components/FamilyTree.tsx +++ b/frontend/src/components/FamilyTree.tsx @@ -11,37 +11,54 @@ import type { Person, VizSettings } from "../types"; import { buildGraph } from "../lib/layout"; import PersonNode from "./PersonNode"; -const nodeTypes = { person: PersonNode }; - function FamilyTreeInner({ - people, settings, onSelect, + people, settings, onSelect, rootId, onSetRoot, }: { people: Person[]; settings: VizSettings; onSelect: (id: string) => void; + rootId: string | null; + onSetRoot: (id: string | null) => void; }) { const [collapsed] = useState>(new Set()); + + const nodeTypes = useMemo(() => ({ + person: (props: any) => ( + + ), + }), [onSetRoot]); + const { nodes, edges } = useMemo( - () => buildGraph(people, settings, collapsed), - [people, settings, collapsed] + () => buildGraph(people, settings, collapsed, rootId), + [people, settings, collapsed, rootId] ); return ( - onSelect(n.id)} - fitView - minZoom={0.1} - maxZoom={2.5} - proOptions={{ hideAttribution: true }} - > - - - - (n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} /> - +
+ {rootId && ( + + )} + onSelect(n.id)} + fitView + minZoom={0.1} + maxZoom={2.5} + proOptions={{ hideAttribution: true }} + > + + + + (n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} /> + +
); } @@ -51,6 +68,8 @@ export default function FamilyTree(props: { people: Person[]; settings: VizSettings; onSelect: (id: string) => void; + rootId: string | null; + onSetRoot: (id: string | null) => void; }) { return ( diff --git a/frontend/src/components/PersonModal.tsx b/frontend/src/components/PersonModal.tsx index 0c3232b..c66a186 100644 --- a/frontend/src/components/PersonModal.tsx +++ b/frontend/src/components/PersonModal.tsx @@ -6,9 +6,25 @@ export default function PersonModal({ id, onClose, onNavigate }: { id: string; onClose: () => void; onNavigate: (id: string) => void; }) { const [p, setP] = useState(null); + const [scanning, setScanning] = useState(false); + const [scanResult, setScanResult] = useState<{ source_name: string; source_url: string } | null>(null); + useEffect(() => { api.person(id).then(setP).catch(() => {}); }, [id]); if (!p) return null; + const handleScanPhoto = async () => { + if (!p) return; + setScanning(true); + setScanResult(null); + try { + const res = await api.scanPhoto(p.id); + if (res.found) { + setP((prev: any) => ({ ...prev, photo_url: res.photo_url, resource_url: res.source_url })); + setScanResult({ source_name: res.source_name, source_url: res.source_url }); + } + } finally { setScanning(false); } + }; + const Chip = ({ rel }: { rel: any }) => ( + {scanResult && ( + + {scanResult.source_name} + + )}

{p.last_name} {p.first_name} {p.middle_name}

diff --git a/frontend/src/components/PersonNode.tsx b/frontend/src/components/PersonNode.tsx index 9ed2717..294c306 100644 --- a/frontend/src/components/PersonNode.tsx +++ b/frontend/src/components/PersonNode.tsx @@ -18,8 +18,9 @@ const fullName = (p: Person) => // Person card refreshed in the shadcn/21st minimal spirit: softer surface, // gender accent bar, smooth hover lift. Ширина расширена до 240px, чтобы ФИО // показывалось целиком; согласовано с NODE_W в lib/layout.ts. -export default function PersonNode({ data }: { data: { person: Person } }) { +export default function PersonNode({ data }: { data: { person: Person; inverted?: boolean; onSetRoot?: (id: string) => void } }) { const p = data.person; + const onSetRoot = data.onSetRoot; const female = p.gender === "female"; const avatar = female ? "bg-female" : "bg-male"; const ring = female ? "ring-female/30" : "ring-male/30"; @@ -63,7 +64,7 @@ export default function PersonNode({ data }: { data: { person: Person } }) { female ? "bg-female" : "bg-male" )} /> - +
{years(p)}
- {/* переход к карточке родителя */} - {parentId && ( - - )} + {/* кнопки навигации */} +
+ {parentId && ( + + )} + {onSetRoot && ( + + )} +
- + ); } diff --git a/frontend/src/lib/layout.ts b/frontend/src/lib/layout.ts index 9d58e57..f4556dd 100644 --- a/frontend/src/lib/layout.ts +++ b/frontend/src/lib/layout.ts @@ -4,8 +4,32 @@ import type { Edge, Node } from 'reactflow'; const NODE_W = 240, NODE_H = 110, V_GAP = 80, COUPLE_GAP = 26, FAM_GAP = 34; // Раскладка генеалогического дерева с поддержкой ориентации и пар. -export function buildGraph(people: Person[], s: VizSettings, collapsed: Set) { - const byId = new Map(people.map(p => [p.id, p])); +export function buildGraph(people: Person[], s: VizSettings, collapsed: Set, rootId?: string | null) { + // Если задан rootId — оставляем только его и всех потомков + let peopleToLayout = people; + if (rootId) { + const byIdFull = new Map(people.map(p => [p.id, p])); + const subtree = new Set(); + const collect = (id: string) => { + if (subtree.has(id)) return; + subtree.add(id); + const p = byIdFull.get(id); + (p?.child_ids || []).forEach(collect); + (p?.spouse_ids || []).forEach(spId => { + if (!subtree.has(spId)) { + subtree.add(spId); + const sp = byIdFull.get(spId); + (sp?.child_ids || []).forEach(collect); + } + }); + }; + collect(rootId); + peopleToLayout = people + .filter(p => subtree.has(p.id)) + .map(p => p.id === rootId ? { ...p, parent_ids: [] } : p); + } + + const byId = new Map(peopleToLayout.map(p => [p.id, p])); const pos: Record = {}; const horiz = s.orientation === 'horizontal'; const BW = horiz ? NODE_H : NODE_W; @@ -13,7 +37,7 @@ export function buildGraph(people: Person[], s: VizSettings, collapsed: Set - people.filter(c => { + peopleToLayout.filter(c => { const pr = c.parent_ids?.[0]; return pr === id || (spouse && pr === spouse); }); @@ -46,16 +70,20 @@ export function buildGraph(people: Person[], s: VizSettings, collapsed: Set !p.parent_ids?.length).forEach(r => layout(r.id, 0)); - people.forEach(p => { if (!pos[p.id]) layout(p.id, 0); }); + peopleToLayout.filter(p => !p.parent_ids?.length).forEach(r => layout(r.id, 0)); + peopleToLayout.forEach(p => { if (!pos[p.id]) layout(p.id, 0); }); - const nodes: Node[] = people.filter(p => pos[p.id]).map(p => { + // Вычисляем maxDepth для инверсии (старшие снизу, младшие сверху) + const maxDepth = Math.max(0, ...Object.values(pos).map(v => v.depth)); + + const nodes: Node[] = peopleToLayout.filter(p => pos[p.id]).map(p => { const { b, depth } = pos[p.id]; + const invertedDepth = maxDepth - depth; return { id: p.id, type: 'person', - position: horiz ? { x: depth * DEP, y: b } : { x: b, y: depth * DEP }, - data: { person: p }, + position: horiz ? { x: invertedDepth * DEP, y: b } : { x: b, y: invertedDepth * DEP }, + data: { person: p, inverted: true }, }; });