/
viktorphp
/
front-socket
Обзор
Документация
Войти
/
viktorphp
/
front-socket
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/app/App.tsx
372 строки
11 KB
Иванов Виктор Евгеньевич
[feat-tree]: добавить страницу дерева предков, поправить основные баги
13 апр 2026, 21:32
13 апр 2026, 21:32
6a6f509
Код
Авторство
О чём код?
'use client'; import { ReactFlowProvider } from '@xyflow/react'; import { useCallback, useEffect, useState } from 'react'; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import AddIcon from '@mui/icons-material/Add'; import { Box, Button, Menu, MenuItem, Typography } from '@mui/material'; import { useAncestorStore } from '@entities/ancestor'; import { useUserStore } from '@entities/user'; import { AddRelationModal, AncestorDetailModal, ConnectRelationModal, CreateAncestorModal, DestroyAncestorModal, DetachAncestorModal, } from '@features/ancestor'; import { ancestorsApi } from '@shared/api/rest'; import { IAncestor } from '@shared/types'; import { FamilyTreeCanvas, FreeSidebar } from '@widgets/familyTree'; export const App = () => { const { user } = useUserStore(); const { setTree, setTreeLoading, setSelectedAncestor, setSelfAvatarUrl } = useAncestorStore(); // Состояния диалогов const [addFromId, setAddFromId] = useState<string | null>(null); const [detailAncestorId, setDetailAncestorId] = useState<string | null>(null); const [createOpen, setCreateOpen] = useState(false); const [deleteAncestor, setDeleteAncestor] = useState<IAncestor | null>(null); const [destroyAncestor, setDestroyAncestor] = useState<IAncestor | null>( null ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // DnD: свободная карточка → карточка дерева const [draggedAncestor, setDraggedAncestor] = useState<IAncestor | null>( null ); const [connectTargetId, setConnectTargetId] = useState<string | null>(null); // Для DragOverlay — отображается во время перетаскивания const [overlayAncestor, setOverlayAncestor] = useState<IAncestor | null>( null ); // Контекстное меню канваса (правый клик) const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number; } | null>(null); // Инициализация: загружаем дерево useEffect(() => { if (!user) return; initTree(); }, [user]); // eslint-disable-line react-hooks/exhaustive-deps const initTree = async () => { if (!user) return; setTreeLoading(true); try { const tree = await ancestorsApi .getUserTree(user.id, 3) .catch(async (err) => { if (err?.status === 404) { await ancestorsApi.createAncestor({ userId: user.id, firstName: user.firstName || undefined, lastName: user.lastName || undefined, patronymic: user.patronymic || undefined, birthYear: user.birthDate ? new Date(user.birthDate).getFullYear() : undefined, isAlive: true, }); return ancestorsApi.getUserTree(user.id, 3); } throw err; }); setTree(tree); loadSelfAvatar(); } catch { console.error('Ошибка загрузки дерева'); } finally { setTreeLoading(false); } }; const loadSelfAvatar = async () => { try { const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'}/profile/users/avatar`, { credentials: 'include' } ); if (res.ok) { const base64 = await res.text(); const mimeType = (res.headers.get('content-type') || 'image/jpeg') .split(';')[0] .trim(); setSelfAvatarUrl(`data:${mimeType};base64,${base64}`); } } catch { // Нет аватара — не критично } }; // Handlers для карточек дерева const handleAddClick = useCallback((ancestorId: string) => { setAddFromId(ancestorId); }, []); const handleCardClick = useCallback( async (ancestorId: string) => { setDetailAncestorId(ancestorId); try { const ancestor = await ancestorsApi.getAncestor(ancestorId); setSelectedAncestor(ancestor); } catch { console.error('Ошибка загрузки предка'); } }, [setSelectedAncestor] ); const handleDetachClick = useCallback(async (ancestorId: string) => { try { const ancestor = await ancestorsApi.getAncestor(ancestorId); setDeleteAncestor(ancestor); } catch { console.error('Ошибка загрузки предка'); } }, []); const handleDestroyClick = useCallback(async (ancestorId: string) => { try { const ancestor = await ancestorsApi.getAncestor(ancestorId); setDestroyAncestor(ancestor); } catch { console.error('Ошибка загрузки предка'); } }, []); const handleDetailClose = useCallback(() => { setDetailAncestorId(null); setSelectedAncestor(null); }, [setSelectedAncestor]); // Контекстное меню на канвасе const handlePaneContextMenu = useCallback( (event: MouseEvent | React.MouseEvent) => { event.preventDefault(); setContextMenu({ mouseX: event.clientX, mouseY: event.clientY }); }, [] ); const handleContextMenuClose = () => setContextMenu(null); const handleContextMenuCreate = () => { setContextMenu(null); setCreateOpen(true); }; // DnD const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, }) ); const handleDragStart = useCallback((event: DragStartEvent) => { const ancestor = event.active.data.current?.ancestor as | IAncestor | undefined; setOverlayAncestor(ancestor ?? null); }, []); const handleDragEnd = useCallback((event: DragEndEvent) => { setOverlayAncestor(null); const { active, over } = event; if (!over) return; const dragged = active.data.current?.ancestor as IAncestor | undefined; const targetId = over.id as string; if (dragged && targetId && dragged.id !== targetId) { setDraggedAncestor(dragged); setConnectTargetId(targetId); } }, []); const handleConnectClose = useCallback(() => { setDraggedAncestor(null); setConnectTargetId(null); }, []); return ( <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > <Box sx={{ width: '100vw', height: '100vh', bgcolor: '#f4f6f9', display: 'flex', flexDirection: 'column', overflow: 'hidden', }} > {/* Тулбар */} <Box sx={{ height: 48, minHeight: 48, bgcolor: '#c8dfc2', borderBottom: '1px solid #c8e0c2', display: 'flex', alignItems: 'center', px: 2, gap: 1.5, zIndex: 10, }} > <Typography variant="h6" fontWeight={700} color="#212121" sx={{ letterSpacing: 0.3 }} > Дерево предков </Typography> <Box sx={{ flex: 1 }} /> <Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)} sx={{ borderColor: '#6CC444', color: '#6CC444', textTransform: 'none', fontSize: 13, bgcolor: 'rgba(255,255,255,0.6)', '&:hover': { borderColor: '#5ab035', bgcolor: 'rgba(255,255,255,0.85)', }, }} > Добавить человека </Button> </Box> {/* Основная область: сайдбар + канвас */} <Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}> <FreeSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((c) => !c)} loading={false} /> <ReactFlowProvider> <FamilyTreeCanvas onAddClick={handleAddClick} onCardClick={handleCardClick} onDetachClick={handleDetachClick} onDestroyClick={handleDestroyClick} onPaneContextMenu={handlePaneContextMenu} /> </ReactFlowProvider> </Box> </Box> {/* Контекстное меню канваса */} <Menu open={!!contextMenu} onClose={handleContextMenuClose} anchorReference="anchorPosition" anchorPosition={ contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined } slotProps={{ paper: { sx: { borderRadius: 2, boxShadow: '0 4px 16px rgba(0,0,0,0.12)' }, }, }} > <MenuItem onClick={handleContextMenuCreate} sx={{ fontSize: 14, gap: 1 }} > <AddIcon fontSize="small" sx={{ color: '#6CC444' }} /> Добавить человека </MenuItem> </Menu> {/* Диалоги */} <CreateAncestorModal open={createOpen} onClose={() => setCreateOpen(false)} /> <ConnectRelationModal freeAncestor={draggedAncestor} targetAncestorId={connectTargetId} onClose={handleConnectClose} /> <DetachAncestorModal ancestor={deleteAncestor} onClose={() => setDeleteAncestor(null)} /> <DestroyAncestorModal ancestor={destroyAncestor} onClose={() => setDestroyAncestor(null)} /> <AncestorDetailModal ancestorId={detailAncestorId} onClose={handleDetailClose} /> <AddRelationModal fromAncestorId={addFromId} onClose={() => setAddFromId(null)} /> {/* DragOverlay — призрак тянущейся карточки */} <DragOverlay> {overlayAncestor && ( <Box sx={{ px: 1.5, py: 0.75, bgcolor: '#fff', borderRadius: 2, border: '1.5px solid #6CC444', boxShadow: '0 4px 16px rgba(0,0,0,0.18)', fontSize: 12, fontWeight: 600, color: '#212121', pointerEvents: 'none', whiteSpace: 'nowrap', }} > {[overlayAncestor.lastName, overlayAncestor.firstName] .filter(Boolean) .join(' ') || 'Без имени'} </Box> )} </DragOverlay> </DndContext> ); };