2026-07-04 16:43:55 +02:00
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
|
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getGlobalFolders,
|
|
|
|
|
|
createGlobalFolder,
|
|
|
|
|
|
renameGlobalFolder,
|
|
|
|
|
|
deleteGlobalFolder,
|
|
|
|
|
|
getGlobalBlocks,
|
|
|
|
|
|
createGlobalBlock,
|
|
|
|
|
|
renameGlobalBlock,
|
|
|
|
|
|
deleteGlobalBlock,
|
2026-08-28 12:25:17 +02:00
|
|
|
|
exportLibrary,
|
|
|
|
|
|
importLibrary,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
} 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<BlockLibraryTreeProps> = ({ token, onBlockDragStart }) => {
|
|
|
|
|
|
const [folders, setFolders] = useState<GlobalBlockFolder[]>([]);
|
|
|
|
|
|
const [blocks, setBlocks] = useState<GlobalBlock[]>([]);
|
|
|
|
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [renamingId, setRenamingId] = useState<string | null>(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<HTMLInputElement>(null);
|
2026-08-28 12:25:17 +02:00
|
|
|
|
const wcadInputRef = useRef<HTMLInputElement>(null);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(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]);
|
|
|
|
|
|
|
2026-08-28 12:25:17 +02:00
|
|
|
|
// ─── Task F1: Library-Paket Export/Import ────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const handleLibraryExport = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pkg = await exportLibrary(token);
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(pkg, null, 2)], { type: 'application/json' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib`;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert(`Library-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [token]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleWcadFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
|
const pkg = JSON.parse(text);
|
|
|
|
|
|
const result = await importLibrary(token, pkg);
|
|
|
|
|
|
alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`);
|
|
|
|
|
|
await loadData();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert(`Library-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [token, loadData]);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Build tree structure from flat lists
|
|
|
|
|
|
const buildTree = useCallback((): FolderNode[] => {
|
|
|
|
|
|
const folderMap = new Map<string, FolderNode>();
|
|
|
|
|
|
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<HTMLInputElement>) => {
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<div key={node.folder.id}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`tree-item tree-item-folder${isDragOver ? ' drag-over' : ''}`}
|
|
|
|
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
|
|
|
|
onClick={() => 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)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="tree-toggle">{node.children.length > 0 || node.blocks.length > 0 ? (node.expanded ? '▾' : '▸') : '·'}</span>
|
|
|
|
|
|
<span className="tree-icon">📁</span>
|
|
|
|
|
|
{isRenaming ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
className="tree-rename-input"
|
|
|
|
|
|
value={renameValue}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
|
|
|
|
onBlur={confirmRename}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') confirmRename();
|
|
|
|
|
|
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="tree-label"
|
|
|
|
|
|
onDoubleClick={(e) => { e.stopPropagation(); startRename(node.folder.id, node.folder.name); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{node.folder.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="tree-count">{node.blocks.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{node.expanded && (
|
|
|
|
|
|
<div className="tree-children">
|
|
|
|
|
|
{node.children.map(child => renderFolderNode(child, depth + 1))}
|
|
|
|
|
|
{node.blocks.map(block => {
|
|
|
|
|
|
const isBlockRenaming = renamingId === block.id;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={block.id}
|
|
|
|
|
|
className="tree-item tree-item-leaf draggable"
|
|
|
|
|
|
style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }}
|
|
|
|
|
|
draggable
|
|
|
|
|
|
onDragStart={(e) => handleBlockDragStart(e, block)}
|
|
|
|
|
|
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
|
|
|
|
|
|
title={block.name}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="tree-icon">📦</span>
|
|
|
|
|
|
{isBlockRenaming ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
className="tree-rename-input"
|
|
|
|
|
|
value={renameValue}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
|
|
|
|
onBlur={confirmRename}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') confirmRename();
|
|
|
|
|
|
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="tree-label"
|
|
|
|
|
|
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{block.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return <div className="global-lib-loading">Globale Bibliothek lädt…</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="global-lib-tree" onContextMenu={(e) => handleContextMenu(e, 'root', null, null)}>
|
|
|
|
|
|
<div className="global-lib-header">
|
|
|
|
|
|
<span className="global-lib-title">🌐 Globale Bibliothek</span>
|
2026-08-28 12:25:17 +02:00
|
|
|
|
<button
|
|
|
|
|
|
className="global-lib-add-btn"
|
|
|
|
|
|
title="Bibliothek als .wcadlib exportieren"
|
|
|
|
|
|
onClick={() => void handleLibraryExport()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 17 17 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="global-lib-add-btn"
|
|
|
|
|
|
title=".wcadlib-Bibliothek importieren"
|
|
|
|
|
|
onClick={() => wcadInputRef.current?.click()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
|
|
|
|
</button>
|
2026-07-04 16:43:55 +02:00
|
|
|
|
<button
|
|
|
|
|
|
className="global-lib-add-btn"
|
|
|
|
|
|
title="SVG in globale Bibliothek importieren"
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
2026-08-28 12:25:17 +02:00
|
|
|
|
<input ref={wcadInputRef} type="file" accept=".wcadlib,application/json" style={{ display: 'none' }} onChange={handleWcadFile} />
|
2026-07-04 16:43:55 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
|
|
|
|
|
|
{tree.map(node => renderFolderNode(node, 0))}
|
|
|
|
|
|
{rootBlocks.map(block => {
|
|
|
|
|
|
const isBlockRenaming = renamingId === block.id;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={block.id}
|
|
|
|
|
|
className="tree-item tree-item-leaf draggable"
|
|
|
|
|
|
draggable
|
|
|
|
|
|
onDragStart={(e) => handleBlockDragStart(e, block)}
|
|
|
|
|
|
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, null)}
|
|
|
|
|
|
title={block.name}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="tree-icon">📦</span>
|
|
|
|
|
|
{isBlockRenaming ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
className="tree-rename-input"
|
|
|
|
|
|
value={renameValue}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
|
|
|
|
onBlur={confirmRename}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') confirmRename();
|
|
|
|
|
|
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="tree-label"
|
|
|
|
|
|
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{block.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{folders.length === 0 && blocks.length === 0 && (
|
|
|
|
|
|
<div className="global-lib-empty">Globale Bibliothek ist leer — Rechtsklick für Ordner</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{contextMenu && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="context-menu"
|
|
|
|
|
|
style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, zIndex: 10000 }}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{contextMenu.type === 'root' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button className="context-menu-item" onClick={() => { handleCreateFolder(null); closeContextMenu(); }}>
|
|
|
|
|
|
📁 Neuer Ordner
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{contextMenu.type === 'folder' && contextMenu.id && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button className="context-menu-item" onClick={() => { handleCreateFolder(contextMenu.id); closeContextMenu(); }}>
|
|
|
|
|
|
📁 Neuer Unterordner
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="context-menu-item" onClick={() => { if (contextMenu.id) startRename(contextMenu.id, folders.find(f => f.id === contextMenu.id)?.name ?? ''); closeContextMenu(); }}>
|
|
|
|
|
|
✏️ Umbenennen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="context-menu-item danger" onClick={() => { if (contextMenu.id) handleDeleteFolder(contextMenu.id); closeContextMenu(); }}>
|
|
|
|
|
|
🗑️ Löschen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{contextMenu.type === 'block' && contextMenu.id && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button className="context-menu-item" onClick={() => { if (contextMenu.id) startRename(contextMenu.id, blocks.find(b => b.id === contextMenu.id)?.name ?? ''); closeContextMenu(); }}>
|
|
|
|
|
|
✏️ Umbenennen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="context-menu-item danger" onClick={() => { if (contextMenu.id) handleDeleteBlock(contextMenu.id); closeContextMenu(); }}>
|
|
|
|
|
|
🗑️ Löschen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default BlockLibraryTree;
|