From 2635b459739ef4cb7997be6d3a5873b60539e1d0 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 20 Mar 2026 08:49:00 +0100 Subject: [PATCH] feat: sidebar tree --- frontend/package-lock.json | 144 ++++- frontend/package.json | 1 + .../layout/RecollectionSidebar.tsx | 304 +---------- .../app/recollections/layout/TreeBrowser.tsx | 492 ++++++++++++++++++ frontend/src/styles.css | 51 ++ 5 files changed, 703 insertions(+), 289 deletions(-) create mode 100644 frontend/src/app/recollections/layout/TreeBrowser.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d5727e..7a13c94 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", "react": "18.2.0", + "react-arborist": "^3.4.3", "react-dom": "18.2.0", "react-router-dom": "^6.28.0", "react-simple-code-editor": "^0.14.1", @@ -519,7 +520,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2927,6 +2927,24 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -3875,7 +3893,7 @@ "version": "25.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -5325,6 +5343,26 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -6472,6 +6510,21 @@ "dev": true, "license": "MIT" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hono": { "version": "4.12.5", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", @@ -7449,6 +7502,12 @@ "node": ">= 0.8" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -9358,6 +9417,62 @@ "node": ">=0.10.0" } }, + "node_modules/react-arborist": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz", + "integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -9525,6 +9640,23 @@ } } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -9575,6 +9707,12 @@ "node": ">= 4" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/rehype-format": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", @@ -10761,7 +10899,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/frontend/package.json b/frontend/package.json index fdec908..2398071 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", "react": "18.2.0", + "react-arborist": "^3.4.3", "react-dom": "18.2.0", "react-router-dom": "^6.28.0", "react-simple-code-editor": "^0.14.1", diff --git a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx index 2d8fc04..8be2157 100644 --- a/frontend/src/app/recollections/layout/RecollectionSidebar.tsx +++ b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx @@ -2,23 +2,14 @@ * Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes. */ -import React, { useCallback, useState } from 'react' +import React, { useCallback } from 'react' import { useParams, useLocation, useNavigate } from 'react-router-dom' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' +import { Plus, FileText } from 'lucide-react' +import { FluxIcon } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { useRecollectionSidebar } from './RecollectionSidebarContext' +import { TreeBrowser } from './TreeBrowser' import { SidebarContent, SidebarGroup, @@ -28,194 +19,31 @@ import { SidebarMenuItem, sidebarMenuButtonVariants, } from '@/components/ui/sidebar' -import { - FileText, - MoreHorizontal, - Pencil, - Plus, - Trash2, - ArrowUp, - ArrowDown, -} from 'lucide-react' -import { FluxIcon } from '@/lib/icons' -import { cn } from '@/lib/utils' -import { useRecollectionSidebar } from './RecollectionSidebarContext' -import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore' - -const DEFAULT_PAGE_TITLE = 'Untitled' - -function generatePageId(): LogosPageId { - return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` -} - -function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) { - return a.position - b.position -} export function RecollectionSidebar() { const { recollectionId } = useParams<{ recollectionId: string }>() const { pathname } = useLocation() const navigate = useNavigate() - const { - tree, - activePageId, - handleSelectPage, - handleTreeChange, - handleDeletePage, - } = useRecollectionSidebar() - - const [renameTarget, setRenameTarget] = useState(null) - const [renameTitle, setRenameTitle] = useState('') - const [deleteTarget, setDeleteTarget] = useState(null) + const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar() const base = recollectionId ? `/recollections/${recollectionId}` : '' const baseLogos = `${base}/logos` const isKatalogosView = pathname.endsWith('/logos/katalogos') const isFluxView = pathname.endsWith('/flux') - const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition) - - const updatePage = useCallback( - (id: LogosPageId, patch: Partial>) => { - const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p)) - handleTreeChange(next) - }, - [tree, handleTreeChange] - ) - - const addPage = useCallback(() => { - const maxPos = Math.max(0, ...pages.map((p) => p.position), -1) - const newPage: LogosPageMeta = { - id: generatePageId(), - title: DEFAULT_PAGE_TITLE, + const handleAddPage = useCallback(() => { + // Add a new top-level page + const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1) + const newPage = { + id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + title: 'Untitled', parentId: null, position: maxPos + 1, } handleTreeChange([...tree, newPage]) - setRenameTarget(newPage) - setRenameTitle(newPage.title) handleSelectPage(newPage.id) navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`) - }, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate]) - - const startRename = useCallback((page: LogosPageMeta) => { - setRenameTarget(page) - setRenameTitle(page.title) - }, []) - - const commitRename = useCallback(() => { - if (renameTarget && renameTitle.trim()) { - updatePage(renameTarget.id, { title: renameTitle.trim() }) - } - setRenameTarget(null) - setRenameTitle('') - }, [renameTarget, renameTitle, updatePage]) - - const movePage = useCallback( - (id: LogosPageId, delta: number) => { - const page = tree.find((p) => p.id === id) - if (!page) return - const siblings = tree - .filter((p) => p.parentId === page.parentId) - .sort(sortByPosition) - const idx = siblings.findIndex((p) => p.id === id) - if (idx < 0) return - const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta)) - if (newIdx === idx) return - const reordered = siblings.slice() - const [removed] = reordered.splice(idx, 1) - reordered.splice(newIdx, 0, removed) - const withNewPositions = tree.map((p) => { - const i = reordered.findIndex((r) => r.id === p.id) - return i >= 0 ? { ...p, position: i } : p - }) - handleTreeChange(withNewPositions) - }, - [tree, handleTreeChange] - ) - - const removePage = useCallback( - (page: LogosPageMeta) => { - handleTreeChange(tree.filter((p) => p.id !== page.id)) - handleDeletePage(page.id) - if (activePageId === page.id) { - const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition) - const first = remaining[0] - if (first) { - handleSelectPage(first.id) - navigate(`${baseLogos}?page=${encodeURIComponent(first.id)}`) - } else { - navigate(baseLogos) - } - } - setDeleteTarget(null) - }, - [tree, activePageId, handleTreeChange, handleDeletePage, handleSelectPage, baseLogos, navigate] - ) - - const onPageClick = useCallback( - (pageId: LogosPageId) => { - handleSelectPage(pageId) - navigate(`${baseLogos}?page=${encodeURIComponent(pageId)}`) - }, - [handleSelectPage, baseLogos, navigate] - ) - - const renderPageActions = useCallback( - (page: LogosPageMeta, options: { canMoveUp: boolean; canMoveDown: boolean }) => { - const { canMoveUp, canMoveDown } = options - return ( - svg]:size-4 [&>svg]:shrink-0', - 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0' - )} - > - - - - - e.stopPropagation()}> - { - startRename(page) - onPageClick(page.id) - }} - > - - Rename - - - movePage(page.id, -1)} disabled={!canMoveUp}> - - Move up - - movePage(page.id, 1)} disabled={!canMoveDown}> - - Move down - - - setDeleteTarget(page)} className="text-destructive focus:text-destructive"> - - Delete - - - - - ) - }, - [startRename, onPageClick, movePage] - ) + }, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate]) return (
New page
- - {pages.length === 0 ? ( -
- -
- ) : ( - pages.map((page) => { - const canMoveUp = pages.indexOf(page) > 0 - const canMoveDown = pages.indexOf(page) < pages.length - 1 - const isActive = pathname.startsWith(baseLogos) && !isKatalogosView && activePageId === page.id - return ( - - - {renderPageActions(page, { canMoveUp, canMoveDown })} - - ) - }) - )} -
+
@@ -286,7 +81,7 @@ export function RecollectionSidebar() { onClick={() => navigate(`${base}/logos/katalogos`)} > - Katalogos + Artifacts @@ -304,76 +99,13 @@ export function RecollectionSidebar() { onClick={() => navigate(`${base}/flux`)} > - Flux + Canvas - - { - if (!open) { - setRenameTarget(null) - setRenameTitle('') - } - }} - > - - - Rename page - - setRenameTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename() - if (e.key === 'Escape') { - setRenameTarget(null) - setRenameTitle('') - } - }} - aria-label="Page title" - /> - - - - - - - - !open && setDeleteTarget(null)}> - - - Delete page? - - {deleteTarget && ( -

- "{deleteTarget.title}" will be permanently removed. -

- )} - - - - -
-
) } diff --git a/frontend/src/app/recollections/layout/TreeBrowser.tsx b/frontend/src/app/recollections/layout/TreeBrowser.tsx new file mode 100644 index 0000000..efa9825 --- /dev/null +++ b/frontend/src/app/recollections/layout/TreeBrowser.tsx @@ -0,0 +1,492 @@ +/** + * TreeBrowser: A tree browser component using react-arborist for managing + * Logos pages in a hierarchical folder structure. + * + * Features: + * - Drag and drop reordering + * - Folder expansion/collapse + * - Context menu for actions + * - Shadcn theme integration + * - Inline editing for page titles + * - Smooth animations + * - Search functionality + * - Expand/collapse all + */ + +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { Tree, NodeApi, RowRendererProps, TreeApi } from 'react-arborist' +import { cn } from '@/lib/utils' +import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, Folder } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { useRecollectionSidebar } from './RecollectionSidebarContext' +import { useParams, useNavigate, useLocation } from 'react-router-dom' +import type { LogosPageMeta, LogosPageId } from '../state/recollectionStore' +import { removeLogosPageContent } from '../state/recollectionStore' + +// Tree node data structure +type TreeNode = { + id: string + data: LogosPageMeta + children?: TreeNode[] + isFolder: boolean +} + +// Convert flat tree to hierarchical structure +function buildTree(pages: LogosPageMeta[]): TreeNode[] { + const pageMap = new Map() + const rootNodes: TreeNode[] = [] + + // First pass: create all nodes + pages.forEach((page) => { + pageMap.set(page.id, { + id: page.id, + data: page, + children: [], + isFolder: false, + }) + }) + + // Second pass: build parent-child relationships + pages.forEach((page) => { + const node = pageMap.get(page.id)! + if (page.parentId === null) { + rootNodes.push(node) + } else { + const parent = pageMap.get(page.parentId) + if (parent) { + parent.children?.push(node) + parent.isFolder = true + } + } + }) + + // Sort children by position + function sortNodes(nodes: TreeNode[]) { + nodes.sort((a, b) => a.data.position - b.data.position) + nodes.forEach((node) => sortNodes(node.children || [])) + } + sortNodes(rootNodes) + + return rootNodes +} + +// Memoize the buildTree result to avoid unnecessary re-creation +function useTreeNodes(tree: LogosPageMeta[]) { + return useMemo(() => buildTree(tree), [tree]) +} + +// Inline editable title component +function EditableTitle({ + title, + onSave, + onCancel, +}: { + title: string + onSave: (newTitle: string) => void + onCancel: () => void +}) { + const inputRef = useRef(null) + + React.useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, []) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSave(inputRef.current?.value || '') + } else if (e.key === 'Escape') { + onCancel() + } + } + + return ( + onSave(e.currentTarget.value)} + onKeyDown={handleKeyDown} + className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background" + onClick={(e) => e.stopPropagation()} + /> + ) +} + +// Tree row renderer +function TreeRow({ + node, + innerRef, + attrs, + children, +}: RowRendererProps) { + const { recollectionId } = useParams<{ recollectionId: string }>() + const navigate = useNavigate() + const { pathname } = useLocation() + const { tree, activePageId, handleSelectPage, handleTreeChange } = useRecollectionSidebar() + + const base = recollectionId ? `/recollections/${recollectionId}` : '' + const baseLogos = `${base}/logos` + const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id + + const handleToggle = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + node.toggle() + }, [node]) + + const handleSelect = useCallback((e: React.MouseEvent) => { + if (!node.state.isEditing && !node.state.isDragging) { + handleSelectPage(node.data.id) + navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`) + } + }, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]) + + const handleRename = useCallback(() => { + node.edit() + }, [node]) + + const handleDelete = useCallback(() => { + // Get all descendants to delete + const toDelete = new Set() + function collectDescendants(id: string) { + toDelete.add(id) + tree.forEach((p) => { + if (p.parentId === id) { + collectDescendants(p.id) + } + }) + } + collectDescendants(node.data.id) + + // Remove content from storage for all deleted pages + if (recollectionId) { + toDelete.forEach((pageId) => { + removeLogosPageContent(recollectionId, pageId) + }) + } + + // Remove from tree + handleTreeChange(tree.filter((p) => !toDelete.has(p.id))) + }, [tree, handleTreeChange, node.data.id, recollectionId]) + + const handleAddChild = useCallback(() => { + const maxPos = Math.max( + 0, + ...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position), + -1 + ) + const newPage: LogosPageMeta = { + id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + title: 'Untitled', + parentId: node.data.id, + position: maxPos + 1, + } + handleTreeChange([...tree, newPage]) + // Expand parent if collapsed + if (!node.isOpen) { + node.open() + } + }, [tree, handleTreeChange, node]) + + const hasChildren = (node.children?.length || 0) > 0 + const level = node.level + + return ( +
+ {/* Visual hierarchy indicator for nested items */} + {level > 0 && ( +
+ )} + + {/* Expand/Collapse toggle for folders */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* Icon */} + {node.data.isFolder ? ( + + ) : ( + + )} + + {/* Title - Editable */} +
+ {node.state.isEditing ? ( + { + if (newTitle.trim()) { + handleTreeChange( + tree.map((p) => + p.id === node.data.id ? { ...p, title: newTitle.trim() } : p + ) + ) + } + node.submit(newTitle) + }} + onCancel={() => { + node.reset() + }} + /> + ) : ( + + {node.data.data.title || 'Untitled'} + + )} +
+ + {/* Actions - Only visible on hover */} +
+ + + + + + e.stopPropagation()}> + + + Rename + + + + + Delete + + + +
+
+ ) +} + +export function TreeBrowser() { + const { tree, handleTreeChange } = useRecollectionSidebar() + const [searchQuery, setSearchQuery] = useState('') + const treeRef = useRef | null>(null) + + // Build tree from flat structure + const treeNodes = useTreeNodes(tree) + + // Filter tree based on search query + const filteredTree = useMemo(() => { + if (!searchQuery.trim()) return treeNodes + + const searchTerm = searchQuery.toLowerCase() + + function filterNodes(nodes: TreeNode[]): TreeNode[] { + const result: TreeNode[] = [] + for (const node of nodes) { + const matches = node.data.title.toLowerCase().includes(searchTerm) + const children = filterNodes(node.children || []) + + if (matches || children.length > 0) { + result.push({ + ...node, + children: children.length > 0 ? children : undefined, + }) + } + } + return result + } + + return filterNodes(treeNodes) + }, [treeNodes, searchQuery]) + + // Get all folder IDs for initial open state + const initialOpenState = useMemo(() => { + const folderIds: Record = {} + function collectIds(nodes: TreeNode[]) { + nodes.forEach((node) => { + if (node.isFolder) { + folderIds[node.id] = true + collectIds(node.children || []) + } + }) + } + collectIds(treeNodes) + return folderIds + }, [treeNodes]) + + // Handle drag and drop reordering + const handleMove = useCallback( + ({ dragIds, parentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => { + const dragId = dragIds[0] + if (!dragId) return + + // Find the dragged page + const draggedPage = tree.find((p) => p.id === dragId) + if (!draggedPage) return + + // Get all siblings at the new location (excluding the dragged item) + const siblings = tree.filter((p) => p.parentId === parentId && p.id !== dragId) + + // Sort siblings by current position + siblings.sort((a, b) => a.position - b.position) + + // Insert dragged item at the new index + siblings.splice(index, 0, { ...draggedPage, parentId, position: index }) + + // Create updated tree with new positions + const updatedTree = tree.map((p) => { + // Find the item in the siblings array + const siblingIndex = siblings.findIndex((s) => s.id === p.id) + + if (siblingIndex >= 0) { + // This item is in the affected parent - update its position + return { ...p, parentId: siblings[siblingIndex].parentId, position: siblingIndex } + } + + // Item is not affected by the move + return p + }) + + handleTreeChange(updatedTree) + }, + [tree, handleTreeChange] + ) + + // Expand all folders + const handleExpandAll = useCallback(() => { + treeRef.current?.openAll() + }, []) + + // Collapse all folders + const handleCollapseAll = useCallback(() => { + treeRef.current?.closeAll() + }, []) + + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery('') + }, []) + + return ( +
+ {/* Search Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border" + /> + {searchQuery && ( + + )} +
+ + + + + + + + Expand all + + + + Collapse all + + + +
+ + {/* Tree */} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9b2b466..81131ad 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -399,4 +399,55 @@ pre { .code-editor .token.entity, .code-editor .token.url { background: none; +} + +/* React Arborist tree styling for sidebar integration */ +.react-arborist-tree { + background: transparent !important; + font-size: 0.875rem; +} + +.react-arborist-tree * { + outline: none !important; +} + +/* Remove default react-arborist styling */ +.react-arborist-tree [role="treeitem"] { + cursor: pointer; +} + +/* Drag preview styling */ +.react-arborist-tree [data-react-arborist-drag-preview] { + background: hsl(var(--sidebar-accent)) !important; + border: 1px solid hsl(var(--sidebar-border)) !important; + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + opacity: 0.9; +} + +/* Drop cursor line */ +.react-arborist-tree [data-react-arborist-drop-cursor] { + background: hsl(var(--sidebar-primary)) !important; + height: 2px !important; + border-radius: 1px; + z-index: 10; +} + +/* Make rows draggable */ +.react-arborist-tree [draggable="true"] { + cursor: grab; +} + +.react-arborist-tree [draggable="true"]:active { + cursor: grabbing; +} + +/* Hide default react-arborist backgrounds */ +.react-arborist-tree [data-react-arborist-tree] { + background: transparent !important; +} + +.react-arborist-tree [data-react-arborist-list-item] { + background: transparent !important; } \ No newline at end of file