From 8b50c0a6121e295086ae8d2bea3d65fb0e0a85d5 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 28 Aug 2026 13:27:57 +0200 Subject: [PATCH] task(F2-frontend): library search, favorites section, star toggle, svg thumbnails, grid view --- frontend/src/components/BlockLibraryTree.tsx | 204 ++++++++++++------- frontend/src/services/api.ts | 12 ++ frontend/src/styles.css | 124 +++++++++++ 3 files changed, 264 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/BlockLibraryTree.tsx b/frontend/src/components/BlockLibraryTree.tsx index df06de7..bec4e51 100644 --- a/frontend/src/components/BlockLibraryTree.tsx +++ b/frontend/src/components/BlockLibraryTree.tsx @@ -9,6 +9,7 @@ import { createGlobalBlock, renameGlobalBlock, deleteGlobalBlock, + setGlobalBlockFavorite, exportLibrary, importLibrary, } from '../services/api'; @@ -43,6 +44,19 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS const wcadInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState(null); + // ─── Task F2: Suche, Favoriten, Thumbnail-Grid ─────────── + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'tree' | 'grid'>('tree'); + + const handleToggleFavorite = useCallback(async (blockId: string, next: boolean) => { + try { + const updated = await setGlobalBlockFavorite(token, blockId, next); + setBlocks(prev => prev.map(b => (b.id === blockId ? { ...b, is_favorite: updated.is_favorite } : b))); + } catch { + // silently ignore — favorite stays unchanged on failure + } + }, [token]); + const loadData = useCallback(async () => { try { const [allFolders, allBlocks] = await Promise.all([ @@ -128,7 +142,10 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS return roots; }, [folders, blocks, expandedIds]); - const rootBlocks = blocks.filter(b => !b.folder_id).sort((a, b) => a.name.localeCompare(b.name)); + const q = searchQuery.toLowerCase().trim(); + const matchesSearch = useCallback((b: GlobalBlock) => !q || b.name.toLowerCase().includes(q), [q]); + const rootBlocks = blocks.filter(b => !b.folder_id && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name)); + const favoriteBlocks = blocks.filter(b => b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name)); const tree = buildTree(); const toggleExpand = (folderId: string) => { @@ -307,6 +324,83 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS setRenameValue(''); }; + // ─── Task F2: Block-Leaf mit Thumbnail + Favorite-Stern ── + const renderBlockLeaf = (block: GlobalBlock, depth: number): React.ReactNode => { + const isBlockRenaming = renamingId === block.id; + return ( +
handleBlockDragStart(e, block)} + onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} + title={block.name} + > + {block.svg_data ? ( + + ) : ( + 📦 + )} + {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} + + )} + +
+ ); + }; + + // Task F2: Grid-Zelle mit Thumbnail + const renderGridCell = (block: GlobalBlock): React.ReactNode => ( +
handleBlockDragStart(e, block)} + onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} + title={`${block.name}${block.is_favorite ? ' ★' : ''}`} + > +
+ {block.svg_data + ? + : 📦} +
+
{block.name}
+ +
+ ); + const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => { const isRenaming = renamingId === node.folder.id; const isDragOver = dragOverFolderId === node.folder.id; @@ -350,44 +444,7 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS {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} - - )} -
- ); - })} + {node.blocks.filter(matchesSearch).map(block => renderBlockLeaf(block, depth + 1))}
)} @@ -426,44 +483,39 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS +
+ setSearchQuery(e.target.value)} + /> + +
- {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} - - )} -
- ); - })} + {favoriteBlocks.length > 0 && ( +
+
★ Favoriten
+ {favoriteBlocks.map(block => renderBlockLeaf(block, 0))} +
+ )} + {viewMode === 'grid' ? ( +
+ {[...favoriteBlocks, ...blocks.filter(b => !b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name))].map(renderGridCell)} +
+ ) : ( + <> + {tree.map(node => renderFolderNode(node, 0))} + {rootBlocks.map(block => renderBlockLeaf(block, 0))} + + )} {folders.length === 0 && blocks.length === 0 && (
Globale Bibliothek ist leer — Rechtsklick für Ordner
)} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6d72498..63414a7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -660,6 +660,7 @@ export interface GlobalBlock { name: string; block_data: string; svg_data: string | null; + is_favorite: boolean; created_at: string; updated_at: string; } @@ -698,6 +699,17 @@ export async function renameGlobalBlock(token: string, id: string, name: string) return res.json(); } +// Task F2: Favorite-Toggle für globale Blöcke +export async function setGlobalBlockFavorite(token: string, id: string, isFavorite: boolean): Promise { + const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, { + method: 'PATCH', + headers: authHeaders(token), + body: JSON.stringify({ is_favorite: isFavorite }), + }); + if (!res.ok) throw new Error('Failed to set favorite'); + return res.json(); +} + export async function deleteGlobalBlock(token: string, id: string): Promise { const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, { method: 'DELETE', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ca4a49f..52a896c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2456,6 +2456,130 @@ body.drawer-open { overflow: hidden; } min-width: 0; } +/* ─── Task F2: Suche, Favoriten, Thumbnail-Grid ────────── */ +.global-lib-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 0 12px 6px; +} + +.global-lib-search { + flex: 1; + min-width: 0; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + color: var(--color-text); + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; +} + +.global-lib-search:focus { + outline: none; + border-color: var(--color-accent); +} + +.lib-fav-section { + border-bottom: 1px solid var(--color-border); + padding-bottom: 4px; + margin-bottom: 4px; +} + +.lib-fav-title { + font-size: 11px; + font-weight: 600; + color: #d9a441; + padding: 2px 8px; +} + +.tree-fav-btn { + margin-left: auto; + background: transparent; + border: none; + color: var(--color-text-faint); + cursor: pointer; + font-size: 12px; + padding: 0 4px; + line-height: 1; + flex-shrink: 0; +} + +.tree-fav-btn:hover { color: var(--color-text); } + +.tree-fav-btn.active { color: #d9a441; } + +.tree-thumb { + width: 16px; + height: 16px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.tree-thumb svg { + width: 100%; + height: 100%; +} + +.lib-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 6px; + padding: 4px 8px; +} + +.lib-grid-cell { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 4px 4px; + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: grab; + background: var(--color-bg-secondary); +} + +.lib-grid-cell:hover { + border-color: var(--color-accent); + background: var(--color-bg-hover); +} + +.lib-grid-cell:active { cursor: grabbing; } + +.lib-grid-thumb { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} + +.lib-grid-thumb .tree-thumb { + width: 36px; + height: 36px; +} + +.lib-grid-placeholder { font-size: 20px; } + +.lib-grid-name { + font-size: 10px; + color: var(--color-text); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lib-grid-cell .tree-fav-btn { + position: absolute; + top: 2px; + right: 2px; +} + /* ─── Context Menu ─────────────────────────────────────── */ .context-menu { background: var(--color-bg-secondary);