import React, { useState, useEffect, useRef, useCallback } from 'react'; import type { GlobalBlockFolder, GlobalBlock } from '../services/api'; import { getGlobalFolders, createGlobalFolder, renameGlobalFolder, deleteGlobalFolder, getGlobalBlocks, createGlobalBlock, renameGlobalBlock, deleteGlobalBlock, } from '../services/api'; interface BlockLibraryTreeProps { token: string; onBlockDragStart: (blockId: string, blockData: string) => void; } interface FolderNode { folder: GlobalBlockFolder; children: FolderNode[]; blocks: GlobalBlock[]; expanded: boolean; } const BlockLibraryTree: React.FC = ({ token, onBlockDragStart }) => { const [folders, setFolders] = useState([]); const [blocks, setBlocks] = useState([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'folder' | 'block' | 'root'; id: string | null; parentId: string | null; } | null>(null); const [loading, setLoading] = useState(true); const fileInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState(null); const loadData = useCallback(async () => { try { const [allFolders, allBlocks] = await Promise.all([ getGlobalFolders(token), getGlobalBlocks(token), ]); setFolders(allFolders); setBlocks(allBlocks); } catch (err) { // Silently handle — user will see empty tree } finally { setLoading(false); } }, [token]); useEffect(() => { loadData(); }, [loadData]); // Build tree structure from flat lists const buildTree = useCallback((): FolderNode[] => { const folderMap = new Map(); for (const f of folders) { folderMap.set(f.id, { folder: f, children: [], blocks: [], expanded: expandedIds.has(f.id) }); } const roots: FolderNode[] = []; for (const f of folders) { const node = folderMap.get(f.id)!; if (f.parent_id && folderMap.has(f.parent_id)) { folderMap.get(f.parent_id)!.children.push(node); } else { roots.push(node); } } // Attach blocks to folders for (const b of blocks) { if (b.folder_id && folderMap.has(b.folder_id)) { folderMap.get(b.folder_id)!.blocks.push(b); } } // Sort children and blocks by name const sortRecursive = (nodes: FolderNode[]) => { nodes.sort((a, b) => a.folder.name.localeCompare(b.folder.name)); for (const n of nodes) { n.blocks.sort((a, b) => a.name.localeCompare(b.name)); sortRecursive(n.children); } }; sortRecursive(roots); return roots; }, [folders, blocks, expandedIds]); const rootBlocks = blocks.filter(b => !b.folder_id).sort((a, b) => a.name.localeCompare(b.name)); const tree = buildTree(); const toggleExpand = (folderId: string) => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(folderId)) next.delete(folderId); else next.add(folderId); return next; }); }; const handleCreateFolder = async (parentId: string | null) => { const name = window.prompt('Ordner-Name:', 'Neuer Ordner'); if (!name) return; try { const created = await createGlobalFolder(token, name, parentId); setFolders(prev => [...prev, created]); if (parentId) { setExpandedIds(prev => new Set(prev).add(parentId)); } } catch { window.alert('Fehler beim Erstellen des Ordners'); } }; const handleRenameFolder = async (id: string, name: string) => { if (!name.trim()) return; try { await renameGlobalFolder(token, id, name); setFolders(prev => prev.map(f => f.id === id ? { ...f, name } : f)); } catch { window.alert('Fehler beim Umbenennen des Ordners'); } }; const handleDeleteFolder = async (id: string) => { if (!window.confirm('Ordner und alle Inhalte löschen?')) return; try { await deleteGlobalFolder(token, id); setFolders(prev => prev.filter(f => f.id !== id && f.parent_id !== id)); // Blocks in deleted folder get folder_id = NULL (SET NULL in schema) setBlocks(prev => prev.map(b => b.folder_id === id ? { ...b, folder_id: null } : b)); } catch { window.alert('Fehler beim Löschen des Ordners'); } }; const handleRenameBlock = async (id: string, name: string) => { if (!name.trim()) return; try { await renameGlobalBlock(token, id, name); setBlocks(prev => prev.map(b => b.id === id ? { ...b, name } : b)); } catch { window.alert('Fehler beim Umbenennen des Blocks'); } }; const handleDeleteBlock = async (id: string) => { if (!window.confirm('Block löschen?')) return; try { await deleteGlobalBlock(token, id); setBlocks(prev => prev.filter(b => b.id !== id)); } catch { window.alert('Fehler beim Löschen des Blocks'); } }; const handleBlockDragStart = (e: React.DragEvent, block: GlobalBlock) => { e.dataTransfer.setData('text/global-block-id', block.id); e.dataTransfer.setData('text/global-block-data', block.block_data); e.dataTransfer.effectAllowed = 'copy'; onBlockDragStart(block.id, block.block_data); }; const handleFolderDragOver = (e: React.DragEvent, folderId: string) => { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(folderId); }; const handleFolderDrop = async (e: React.DragEvent, folderId: string) => { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(null); // Drop an SVG file into folder const files = e.dataTransfer.files; if (files && files.length > 0) { const file = files[0]; if (file.type === 'image/svg+xml' || file.name.endsWith('.svg')) { const reader = new FileReader(); reader.onload = async (ev) => { const svgContent = ev.target?.result as string; try { const created = await createGlobalBlock(token, { name: file.name.replace(/\.svg$/i, ''), folder_id: folderId, block_data: JSON.stringify({ type: 'svg', svg: svgContent }), svg_data: svgContent, }); setBlocks(prev => [...prev, created]); } catch { window.alert('Fehler beim Speichern des SVG-Blocks'); } }; reader.readAsText(file); } } // Drop a canvas element (text/global-block-data means it's already a global block, skip) const canvasElementData = e.dataTransfer.getData('text/canvas-element'); if (canvasElementData) { try { const elementData = JSON.parse(canvasElementData); const created = await createGlobalBlock(token, { name: `Element ${Date.now()}`, folder_id: folderId, block_data: JSON.stringify(elementData), }); setBlocks(prev => [...prev, created]); } catch { window.alert('Fehler beim Speichern des Elements als Block'); } } }; const handleSvgImport = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (ev) => { const svgContent = ev.target?.result as string; try { const created = await createGlobalBlock(token, { name: file.name.replace(/\.svg$/i, ''), block_data: JSON.stringify({ type: 'svg', svg: svgContent }), svg_data: svgContent, }); setBlocks(prev => [...prev, created]); } catch { window.alert('Fehler beim Importieren des SVG'); } }; reader.readAsText(file); e.target.value = ''; }; const handleContextMenu = (e: React.MouseEvent, type: 'folder' | 'block' | 'root', id: string | null, parentId: string | null) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, type, id, parentId }); }; const closeContextMenu = () => setContextMenu(null); useEffect(() => { if (contextMenu) { const handleClick = () => closeContextMenu(); window.addEventListener('click', handleClick); return () => window.removeEventListener('click', handleClick); } }, [contextMenu]); const startRename = (id: string, currentName: string) => { setRenamingId(id); setRenameValue(currentName); }; const confirmRename = () => { if (!renamingId) return; const isFolder = folders.some(f => f.id === renamingId); if (isFolder) { handleRenameFolder(renamingId, renameValue); } else { handleRenameBlock(renamingId, renameValue); } setRenamingId(null); setRenameValue(''); }; const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => { const isRenaming = renamingId === node.folder.id; const isDragOver = dragOverFolderId === node.folder.id; return (
toggleExpand(node.folder.id)} onContextMenu={(e) => handleContextMenu(e, 'folder', node.folder.id, node.folder.parent_id)} onDragOver={(e) => handleFolderDragOver(e, node.folder.id)} onDragLeave={() => setDragOverFolderId(null)} onDrop={(e) => handleFolderDrop(e, node.folder.id)} > {node.children.length > 0 || node.blocks.length > 0 ? (node.expanded ? '▾' : '▸') : '·'} 📁 {isRenaming ? ( setRenameValue(e.target.value)} onBlur={confirmRename} onKeyDown={(e) => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startRename(node.folder.id, node.folder.name); }} > {node.folder.name} )} {node.blocks.length}
{node.expanded && (
{node.children.map(child => renderFolderNode(child, depth + 1))} {node.blocks.map(block => { const isBlockRenaming = renamingId === block.id; return (
handleBlockDragStart(e, block)} onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} title={block.name} > 📦 {isBlockRenaming ? ( setRenameValue(e.target.value)} onBlur={confirmRename} onKeyDown={(e) => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startRename(block.id, block.name); }} > {block.name} )}
); })}
)}
); }; if (loading) { return
Globale Bibliothek lädt…
; } return (
handleContextMenu(e, 'root', null, null)}>
🌐 Globale Bibliothek
{tree.map(node => renderFolderNode(node, 0))} {rootBlocks.map(block => { const isBlockRenaming = renamingId === block.id; return (
handleBlockDragStart(e, block)} onContextMenu={(e) => handleContextMenu(e, 'block', block.id, null)} title={block.name} > 📦 {isBlockRenaming ? ( setRenameValue(e.target.value)} onBlur={confirmRename} onKeyDown={(e) => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } }} /> ) : ( { e.stopPropagation(); startRename(block.id, block.name); }} > {block.name} )}
); })} {folders.length === 0 && blocks.length === 0 && (
Globale Bibliothek ist leer — Rechtsklick für Ordner
)}
{contextMenu && (
e.stopPropagation()} > {contextMenu.type === 'root' && ( <> )} {contextMenu.type === 'folder' && contextMenu.id && ( <> )} {contextMenu.type === 'block' && contextMenu.id && ( <> )}
)}
); }; export default BlockLibraryTree;