481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
/**
|
|
* Logos sidebar: one-level page/subpage hierarchy. Create, rename, delete, reorder.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
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 {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
FileText,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Plus,
|
|
Trash2,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
} from 'lucide-react'
|
|
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
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 type LogosSidebarProps = {
|
|
recollectionId: string
|
|
tree: LogosPageMeta[]
|
|
onTreeChange: (tree: LogosPageMeta[]) => void
|
|
activePageId: LogosPageId | null
|
|
onSelectPage: (id: LogosPageId) => void
|
|
onDeletePage?: (pageId: LogosPageId) => void
|
|
}
|
|
|
|
export function LogosSidebar({
|
|
recollectionId,
|
|
tree,
|
|
onTreeChange,
|
|
activePageId,
|
|
onSelectPage,
|
|
onDeletePage,
|
|
}: LogosSidebarProps) {
|
|
const [expandedPages, setExpandedPages] = useState<Set<LogosPageId>>(new Set())
|
|
const expandedInitializedRef = useRef(false)
|
|
const [editingId, setEditingId] = useState<LogosPageId | null>(null)
|
|
const [editTitle, setEditTitle] = useState('')
|
|
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
|
|
|
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
|
|
|
// Default: expand parent pages that have subpages so children are visible on first load.
|
|
useEffect(() => {
|
|
if (tree.length === 0 || expandedInitializedRef.current) return
|
|
expandedInitializedRef.current = true
|
|
const parentIdsWithChildren = tree
|
|
.filter((p) => p.parentId === null && tree.some((s) => s.parentId === p.id))
|
|
.map((p) => p.id)
|
|
if (parentIdsWithChildren.length > 0) {
|
|
setExpandedPages((prev) => {
|
|
const next = new Set(prev)
|
|
parentIdsWithChildren.forEach((id) => next.add(id))
|
|
return next
|
|
})
|
|
}
|
|
}, [tree])
|
|
|
|
const toggleExpanded = useCallback((id: LogosPageId) => {
|
|
setExpandedPages((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const updatePage = useCallback(
|
|
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
|
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
|
onTreeChange(next)
|
|
},
|
|
[tree, onTreeChange]
|
|
)
|
|
|
|
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,
|
|
position: maxPos + 1,
|
|
}
|
|
onTreeChange([...tree, newPage])
|
|
setEditingId(newPage.id)
|
|
setEditTitle(newPage.title)
|
|
onSelectPage(newPage.id)
|
|
}, [tree, pages, onTreeChange, onSelectPage])
|
|
|
|
const addSubpage = useCallback(
|
|
(parentId: LogosPageId) => {
|
|
const siblings = tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
|
|
const maxPos = siblings.length === 0 ? 0 : Math.max(...siblings.map((p) => p.position)) + 1
|
|
const newPage: LogosPageMeta = {
|
|
id: generatePageId(),
|
|
title: DEFAULT_PAGE_TITLE,
|
|
parentId,
|
|
position: maxPos,
|
|
}
|
|
onTreeChange([...tree, newPage])
|
|
setExpandedPages((prev) => new Set(prev).add(parentId))
|
|
setEditingId(newPage.id)
|
|
setEditTitle(newPage.title)
|
|
onSelectPage(newPage.id)
|
|
},
|
|
[tree, onTreeChange, onSelectPage]
|
|
)
|
|
|
|
const startRename = useCallback((page: LogosPageMeta) => {
|
|
setEditingId(page.id)
|
|
setEditTitle(page.title)
|
|
}, [])
|
|
|
|
const commitRename = useCallback(() => {
|
|
if (editingId && editTitle.trim()) {
|
|
updatePage(editingId, { title: editTitle.trim() })
|
|
}
|
|
setEditingId(null)
|
|
setEditTitle('')
|
|
}, [editingId, editTitle, 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
|
|
})
|
|
onTreeChange(withNewPositions)
|
|
},
|
|
[tree, onTreeChange]
|
|
)
|
|
|
|
const removePage = useCallback(
|
|
(page: LogosPageMeta) => {
|
|
const toRemove = [page.id, ...tree.filter((p) => p.parentId === page.id).map((p) => p.id)]
|
|
onTreeChange(tree.filter((p) => !toRemove.includes(p.id)))
|
|
toRemove.forEach((id) => onDeletePage?.(id))
|
|
if (activePageId && toRemove.includes(activePageId)) {
|
|
const remaining = tree.filter((p) => !toRemove.includes(p.id)).sort(sortByPosition)
|
|
const first = remaining[0]
|
|
if (first) onSelectPage(first.id)
|
|
}
|
|
setDeleteTarget(null)
|
|
},
|
|
[tree, activePageId, onTreeChange, onDeletePage, onSelectPage]
|
|
)
|
|
|
|
const subpages = (parentId: LogosPageId) =>
|
|
tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
|
|
|
|
const isPage = (p: LogosPageMeta) => p.parentId === null
|
|
|
|
return (
|
|
<div className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30">
|
|
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-2 py-2">
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Pages
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
onClick={addPage}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
New page
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto py-1">
|
|
{pages.length === 0 ? (
|
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
|
No pages yet.
|
|
<br />
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
className="h-auto p-0 text-xs"
|
|
onClick={addPage}
|
|
>
|
|
Create your first page
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-0.5 px-1">
|
|
{pages.map((page) => {
|
|
const subs = subpages(page.id)
|
|
const expanded = expandedPages.has(page.id)
|
|
const isActive = activePageId === page.id
|
|
return (
|
|
<li key={page.id} className="flex flex-col gap-0.5">
|
|
<PageRow
|
|
page={page}
|
|
isSubpage={false}
|
|
isActive={isActive}
|
|
isEditing={editingId === page.id}
|
|
editTitle={editTitle}
|
|
onEditTitleChange={setEditTitle}
|
|
onCommitRename={commitRename}
|
|
onStartRename={() => startRename(page)}
|
|
onSelect={() => onSelectPage(page.id)}
|
|
onExpandToggle={() => toggleExpanded(page.id)}
|
|
expanded={expanded}
|
|
hasSubpages={subs.length > 0}
|
|
onAddSubpage={() => addSubpage(page.id)}
|
|
onMoveUp={() => movePage(page.id, -1)}
|
|
onMoveDown={() => movePage(page.id, 1)}
|
|
onDelete={() => setDeleteTarget(page)}
|
|
canMoveUp={pages.indexOf(page) > 0}
|
|
canMoveDown={pages.indexOf(page) < pages.length - 1}
|
|
/>
|
|
{expanded && (
|
|
<ul className="ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-2">
|
|
{subs.map((sub, i) => {
|
|
const subActive = activePageId === sub.id
|
|
const subEditing = editingId === sub.id
|
|
return (
|
|
<li key={sub.id}>
|
|
<PageRow
|
|
page={sub}
|
|
isSubpage
|
|
isActive={subActive}
|
|
isEditing={subEditing}
|
|
editTitle={editTitle}
|
|
onEditTitleChange={setEditTitle}
|
|
onCommitRename={commitRename}
|
|
onStartRename={() => startRename(sub)}
|
|
onSelect={() => onSelectPage(sub.id)}
|
|
onAddSubpage={undefined}
|
|
onMoveUp={() => movePage(sub.id, -1)}
|
|
onMoveDown={() => movePage(sub.id, 1)}
|
|
onDelete={() => setDeleteTarget(sub)}
|
|
canMoveUp={i > 0}
|
|
canMoveDown={i < subs.length - 1}
|
|
/>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{deleteTarget && isPage(deleteTarget)
|
|
? 'Delete page and subpages?'
|
|
: 'Delete subpage?'}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{deleteTarget && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{isPage(deleteTarget) && subpages(deleteTarget.id).length > 0 ? (
|
|
<>
|
|
"{deleteTarget.title}" and its {subpages(deleteTarget.id).length}{' '}
|
|
subpage(s) will be permanently removed.
|
|
</>
|
|
) : (
|
|
<> "{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>
|
|
)
|
|
}
|
|
|
|
type PageRowProps = {
|
|
page: LogosPageMeta
|
|
isSubpage: boolean
|
|
isActive: boolean
|
|
isEditing: boolean
|
|
editTitle: string
|
|
onEditTitleChange: (v: string) => void
|
|
onCommitRename: () => void
|
|
onStartRename: () => void
|
|
onSelect: () => void
|
|
onExpandToggle?: () => void
|
|
expanded?: boolean
|
|
hasSubpages?: boolean
|
|
onAddSubpage?: () => void
|
|
onMoveUp: () => void
|
|
onMoveDown: () => void
|
|
onDelete: () => void
|
|
canMoveUp: boolean
|
|
canMoveDown: boolean
|
|
}
|
|
|
|
function PageRow({
|
|
page,
|
|
isSubpage,
|
|
isActive,
|
|
isEditing,
|
|
editTitle,
|
|
onEditTitleChange,
|
|
onCommitRename,
|
|
onStartRename,
|
|
onSelect,
|
|
onExpandToggle,
|
|
expanded,
|
|
hasSubpages,
|
|
onAddSubpage,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
onDelete,
|
|
canMoveUp,
|
|
canMoveDown,
|
|
}: PageRowProps) {
|
|
const isParentRow = onExpandToggle != null
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') onCommitRename()
|
|
if (e.key === 'Escape') {
|
|
onEditTitleChange(page.title)
|
|
onCommitRename()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'group flex items-center gap-0.5 rounded-md pr-1',
|
|
isActive && 'bg-accent text-accent-foreground'
|
|
)}
|
|
>
|
|
{isParentRow ? (
|
|
// VSCode-like "twisty gutter" so labels align nicely.
|
|
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
|
{hasSubpages ? (
|
|
<button
|
|
type="button"
|
|
className="flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
onExpandToggle?.()
|
|
}}
|
|
aria-expanded={expanded}
|
|
aria-label={expanded ? 'Collapse subpages' : 'Expand subpages'}
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="size-3.5 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="size-3.5 shrink-0" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="inline-block size-4" aria-hidden />
|
|
)}
|
|
</span>
|
|
) : isSubpage ? (
|
|
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
|
<span className="inline-block size-4" aria-hidden />
|
|
</span>
|
|
) : null}
|
|
{isSubpage && <span className="w-2 shrink-0" />}
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
value={editTitle}
|
|
onChange={(e) => onEditTitleChange(e.target.value)}
|
|
onBlur={onCommitRename}
|
|
onKeyDown={handleKeyDown}
|
|
autoFocus
|
|
aria-label="Page title"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1.5 text-left text-sm hover:bg-accent/80"
|
|
onClick={onSelect}
|
|
>
|
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
|
</button>
|
|
)}
|
|
{!isEditing && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100"
|
|
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={() => { onStartRename(); onSelect(); }}>
|
|
<Pencil className="mr-2 size-3.5" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
{onAddSubpage != null && (
|
|
<DropdownMenuItem onClick={onAddSubpage}>
|
|
<Plus className="mr-2 size-3.5" />
|
|
New subpage
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={onMoveUp} disabled={!canMoveUp}>
|
|
<ArrowUp className="mr-2 size-3.5" />
|
|
Move up
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={onMoveDown} disabled={!canMoveDown}>
|
|
<ArrowDown className="mr-2 size-3.5" />
|
|
Move down
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
|
<Trash2 className="mr-2 size-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|