frontend: sync deployed changes from vm-mts1 — полное ФИО + переход к родителю (PersonNode), ReactFlowProvider (FamilyTree), ширины узлов (layout)

This commit is contained in:
laptop-agent
2026-06-30 11:57:54 +03:00
parent aca0a5c905
commit 14127d3f44
3 changed files with 122 additions and 22 deletions
+28 -8
View File
@@ -1,13 +1,19 @@
import { useMemo, useState } from 'react';
import ReactFlow, { Background, Controls, MiniMap, type Node } from 'reactflow';
import 'reactflow/dist/style.css';
import type { Person, VizSettings } from '../types';
import { buildGraph } from '../lib/layout';
import PersonNode from './PersonNode';
import { useMemo, useState } from "react";
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider,
type Node,
} from "reactflow";
import "reactflow/dist/style.css";
import type { Person, VizSettings } from "../types";
import { buildGraph } from "../lib/layout";
import PersonNode from "./PersonNode";
const nodeTypes = { person: PersonNode };
export default function FamilyTree({
function FamilyTreeInner({
people, settings, onSelect,
}: {
people: Person[];
@@ -34,7 +40,21 @@ export default function FamilyTree({
<Background gap={24} />
<Controls />
<MiniMap pannable zoomable nodeColor={(n) =>
(n.data?.person?.gender === 'female') ? '#9C5A6F' : '#3F6F6F'} />
(n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} />
</ReactFlow>
);
}
// ReactFlowProvider гарантирует доступность useReactFlow() внутри карточек узлов
// (нужно для плавного перехода к карточке родителя).
export default function FamilyTree(props: {
people: Person[];
settings: VizSettings;
onSelect: (id: string) => void;
}) {
return (
<ReactFlowProvider>
<FamilyTreeInner {...props} />
</ReactFlowProvider>
);
}
+93 -13
View File
@@ -1,28 +1,108 @@
import { Handle, Position } from 'reactflow';
import type { Person } from '../types';
import { Handle, Position, useReactFlow } from "reactflow";
import type { Person } from "../types";
import { cn } from "@/lib/utils";
const initials = (p: Person) =>
((p.first_name?.[0] || "") + (p.last_name?.[0] || "")).toUpperCase();
const initials = (p: Person) => ((p.first_name?.[0] || '') + (p.last_name?.[0] || '')).toUpperCase();
const years = (p: Person) => {
const b = p.birth_date?.slice(0, 4) || '?';
const b = p.birth_date?.slice(0, 4) || "?";
const d = p.death_date?.slice(0, 4);
return d ? `${b} ${d}` : `р. ${b}`;
};
// Полное ФИО в порядке «Фамилия Имя Отчество» (части склеиваются без пустот).
const fullName = (p: Person) =>
[p.last_name, p.first_name, p.middle_name].filter(Boolean).join(" ");
// 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 } }) {
const p = data.person;
const accent = p.gender === 'female' ? 'border-female' : 'border-male';
const avatar = p.gender === 'female' ? 'bg-female' : 'bg-male';
const female = p.gender === "female";
const avatar = female ? "bg-female" : "bg-male";
const ring = female ? "ring-female/30" : "ring-male/30";
const { getNode, setCenter, getZoom, setNodes } = useReactFlow();
const parentId = p.parent_ids?.[0];
// Плавно центрирует вид на карточке родителя и выделяет его.
const goToParent = (e: React.MouseEvent) => {
e.stopPropagation(); // не открывать модалку при переходе к родителю
if (!parentId) return; // корень (Михаил) — родителя нет
const parent = getNode(parentId);
if (!parent) return;
const w = (parent.width ?? 240) as number;
const h = (parent.height ?? 110) as number;
setCenter(
parent.position.x + w / 2,
parent.position.y + h / 2,
{ zoom: getZoom(), duration: 600 }
);
setNodes((nds) =>
nds.map((n) => ({ ...n, selected: n.id === parentId }))
);
};
return (
<div className={`w-[180px] h-[96px] rounded-xl border-2 ${accent} bg-cream dark:bg-ink-2 shadow-md flex items-center gap-3 px-3`}>
<div
className={cn(
"group relative w-[240px] min-h-[110px] overflow-hidden rounded-xl",
"border border-gold/25 bg-cream dark:bg-ink-2 shadow-sm",
"flex items-center gap-3 pl-4 pr-3 py-2",
"transition-all duration-200 cursor-pointer",
"hover:-translate-y-0.5 hover:shadow-md hover:border-gold/60"
)}
>
{/* gender accent bar */}
<span
className={cn(
"absolute left-0 top-0 h-full w-1.5",
female ? "bg-female" : "bg-male"
)}
/>
<Handle type="target" position={Position.Top} className="opacity-0" />
<div className={`w-12 h-12 rounded-full ${avatar} text-cream grid place-items-center font-serif font-bold flex-none`}>
{p.photo_url ? <img src={p.photo_url} className="w-full h-full rounded-full object-cover" /> : initials(p)}
<div
className={cn(
"flex-none w-12 h-12 rounded-full grid place-items-center font-serif font-bold text-cream",
"ring-2 ring-offset-2 ring-offset-cream dark:ring-offset-ink-2 transition-all duration-200",
avatar, ring, "group-hover:ring-4"
)}
>
{p.photo_url ? (
<img src={p.photo_url} className="w-full h-full rounded-full object-cover" />
) : (
initials(p)
)}
</div>
<div className="min-w-0">
<div className="font-serif font-medium text-sm text-ink dark:text-cream truncate">{p.first_name} {p.last_name}</div>
<div className="text-xs text-taupe truncate">{p.middle_name}</div>
<div className="text-xs text-taupe/80">{years(p)}</div>
<div className="min-w-0 flex-1">
<div className="font-serif font-medium text-sm leading-snug text-ink dark:text-cream whitespace-normal break-words">
{fullName(p)}
</div>
<div className="text-xs tabular-nums text-taupe/80 mt-0.5">{years(p)}</div>
</div>
{/* переход к карточке родителя */}
{parentId && (
<button
type="button"
onClick={goToParent}
title="К родителю"
aria-label="Перейти к карточке родителя"
className={cn(
"flex-none self-start -mr-1 mt-0.5 w-6 h-6 rounded-full grid place-items-center",
"text-gold/70 hover:text-cream hover:bg-gold/80 border border-gold/40",
"transition-colors duration-150 nodrag"
)}
>
<span className="text-sm leading-none"></span>
</button>
)}
<Handle type="source" position={Position.Bottom} className="opacity-0" />
</div>
);
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Person, VizSettings } from '../types';
import type { Edge, Node } from 'reactflow';
const NODE_W = 180, NODE_H = 96, V_GAP = 80, COUPLE_GAP = 26, FAM_GAP = 34;
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<string>) {