feat: sidebar tree
This commit is contained in:
144
frontend/package-lock.json
generated
144
frontend/package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
@@ -519,7 +520,6 @@
|
|||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -2927,6 +2927,24 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@remirror/core-constants": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
@@ -3875,7 +3893,7 @@
|
|||||||
"version": "25.3.3",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -5325,6 +5343,26 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -6472,6 +6510,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||||
@@ -7449,6 +7502,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
@@ -9358,6 +9417,62 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -9575,6 +9707,12 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/rehype-format": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
||||||
@@ -10761,7 +10899,7 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
|||||||
@@ -2,23 +2,14 @@
|
|||||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
* 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 { 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 { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Plus, FileText } from 'lucide-react'
|
||||||
Dialog,
|
import { FluxIcon } from '@/lib/icons'
|
||||||
DialogContent,
|
import { cn } from '@/lib/utils'
|
||||||
DialogFooter,
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
DialogHeader,
|
import { TreeBrowser } from './TreeBrowser'
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
@@ -28,194 +19,31 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
sidebarMenuButtonVariants,
|
sidebarMenuButtonVariants,
|
||||||
} from '@/components/ui/sidebar'
|
} 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() {
|
export function RecollectionSidebar() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||||
tree,
|
|
||||||
activePageId,
|
|
||||||
handleSelectPage,
|
|
||||||
handleTreeChange,
|
|
||||||
handleDeletePage,
|
|
||||||
} = useRecollectionSidebar()
|
|
||||||
|
|
||||||
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
|
|
||||||
const [renameTitle, setRenameTitle] = useState('')
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
|
||||||
|
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
const baseLogos = `${base}/logos`
|
const baseLogos = `${base}/logos`
|
||||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||||
const isFluxView = pathname.endsWith('/flux')
|
const isFluxView = pathname.endsWith('/flux')
|
||||||
|
|
||||||
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
const handleAddPage = useCallback(() => {
|
||||||
|
// Add a new top-level page
|
||||||
const updatePage = useCallback(
|
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||||
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
const newPage = {
|
||||||
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
handleTreeChange(next)
|
title: 'Untitled',
|
||||||
},
|
|
||||||
[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,
|
|
||||||
parentId: null,
|
parentId: null,
|
||||||
position: maxPos + 1,
|
position: maxPos + 1,
|
||||||
}
|
}
|
||||||
handleTreeChange([...tree, newPage])
|
handleTreeChange([...tree, newPage])
|
||||||
setRenameTarget(newPage)
|
|
||||||
setRenameTitle(newPage.title)
|
|
||||||
handleSelectPage(newPage.id)
|
handleSelectPage(newPage.id)
|
||||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||||
}, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
}, [tree, 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 (
|
|
||||||
<span
|
|
||||||
data-sidebar="menu-action"
|
|
||||||
className={cn(
|
|
||||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-3.5" />
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
startRename(page)
|
|
||||||
onPageClick(page.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="mr-2 size-3.5" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => movePage(page.id, -1)} disabled={!canMoveUp}>
|
|
||||||
<ArrowUp className="mr-2 size-3.5" />
|
|
||||||
Move up
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => movePage(page.id, 1)} disabled={!canMoveDown}>
|
|
||||||
<ArrowDown className="mr-2 size-3.5" />
|
|
||||||
Move down
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteTarget(page)} className="text-destructive focus:text-destructive">
|
|
||||||
<Trash2 className="mr-2 size-3.5" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[startRename, onPageClick, movePage]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -231,47 +59,14 @@ export function RecollectionSidebar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
onClick={addPage}
|
onClick={handleAddPage}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
New page
|
New page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<TreeBrowser />
|
||||||
{pages.length === 0 ? (
|
|
||||||
<div className="px-2 py-2 text-center text-xs text-sidebar-foreground/70">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0 text-xs text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
|
||||||
onClick={addPage}
|
|
||||||
>
|
|
||||||
Create your first page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<SidebarMenuItem key={page.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
|
||||||
onClick={() => onPageClick(page.id)}
|
|
||||||
>
|
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
||||||
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
|
||||||
</button>
|
|
||||||
{renderPageActions(page, { canMoveUp, canMoveDown })}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -286,7 +81,7 @@ export function RecollectionSidebar() {
|
|||||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||||
>
|
>
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">Katalogos</span>
|
<span className="truncate">Artifacts</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -304,76 +99,13 @@ export function RecollectionSidebar() {
|
|||||||
onClick={() => navigate(`${base}/flux`)}
|
onClick={() => navigate(`${base}/flux`)}
|
||||||
>
|
>
|
||||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">Flux</span>
|
<span className="truncate">Canvas</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={!!renameTarget}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRenameTarget(null)
|
|
||||||
setRenameTitle('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Rename page</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={renameTitle}
|
|
||||||
onChange={(e) => setRenameTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') commitRename()
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setRenameTarget(null)
|
|
||||||
setRenameTitle('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Page title"
|
|
||||||
/>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button type="button" variant="outline" onClick={() => { setRenameTarget(null); setRenameTitle('') }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={commitRename}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete page?</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{deleteTarget && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
"{deleteTarget.title}" will be permanently removed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => deleteTarget && removePage(deleteTarget)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
492
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
492
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
@@ -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<string, TreeNode>()
|
||||||
|
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<HTMLInputElement>(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 (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={title}
|
||||||
|
onBlur={(e) => 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<TreeNode>) {
|
||||||
|
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<string>()
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={innerRef}
|
||||||
|
{...attrs}
|
||||||
|
style={{
|
||||||
|
...attrs.style,
|
||||||
|
paddingLeft: `${level * 20 + 8}px`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md',
|
||||||
|
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||||
|
node.state.isDragging && 'opacity-50',
|
||||||
|
node.state.willReceiveDrop && 'bg-sidebar-accent/50'
|
||||||
|
)}
|
||||||
|
onClick={handleSelect}
|
||||||
|
>
|
||||||
|
{/* Visual hierarchy indicator for nested items */}
|
||||||
|
{level > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 border-l-2 border-sidebar-border/50"
|
||||||
|
style={{ left: `${(level - 1) * 20 + 16}px` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expand/Collapse toggle for folders */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{node.isOpen ? (
|
||||||
|
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{node.data.isFolder ? (
|
||||||
|
<Folder className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title - Editable */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{node.state.isEditing ? (
|
||||||
|
<EditableTitle
|
||||||
|
title={node.data.data.title}
|
||||||
|
onSave={(newTitle) => {
|
||||||
|
if (newTitle.trim()) {
|
||||||
|
handleTreeChange(
|
||||||
|
tree.map((p) =>
|
||||||
|
p.id === node.data.id ? { ...p, title: newTitle.trim() } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
node.submit(newTitle)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
node.reset()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate block select-none">
|
||||||
|
{node.data.data.title || 'Untitled'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions - Only visible on hover */}
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddChild()
|
||||||
|
}}
|
||||||
|
title="Add child page"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem onClick={handleRename}>
|
||||||
|
<Pencil className="mr-2 size-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 size-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeBrowser() {
|
||||||
|
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const treeRef = useRef<TreeApi<TreeNode> | 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<string, boolean> = {}
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
{/* Search Toolbar */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search pages..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
title="Expand/Collapse options"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleExpandAll}>
|
||||||
|
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||||
|
Expand all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||||
|
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||||
|
Collapse all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden px-2">
|
||||||
|
<Tree
|
||||||
|
ref={treeRef}
|
||||||
|
data={filteredTree}
|
||||||
|
idAccessor="id"
|
||||||
|
childrenAccessor="children"
|
||||||
|
width="100%"
|
||||||
|
height={600}
|
||||||
|
rowHeight={32}
|
||||||
|
indent={0}
|
||||||
|
renderRow={TreeRow}
|
||||||
|
initialOpenState={initialOpenState}
|
||||||
|
onMove={handleMove}
|
||||||
|
disableDrag={!!searchQuery}
|
||||||
|
disableDrop={!!searchQuery}
|
||||||
|
className="react-arborist-tree"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -399,4 +399,55 @@ pre {
|
|||||||
.code-editor .token.entity,
|
.code-editor .token.entity,
|
||||||
.code-editor .token.url {
|
.code-editor .token.url {
|
||||||
background: none;
|
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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user