609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
/**
|
|
* 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 { useResizeHeight } from '@/hooks/useResizeHeight'
|
|
import {
|
|
Tree,
|
|
NodeApi,
|
|
NodeRendererProps,
|
|
RowRendererProps,
|
|
TreeApi,
|
|
type CursorProps,
|
|
} from 'react-arborist'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
FileText,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Plus,
|
|
MoreHorizontal,
|
|
Trash2,
|
|
Pencil,
|
|
Search,
|
|
ChevronsDownUp,
|
|
ChevronsUpDown,
|
|
X,
|
|
GripVertical,
|
|
} 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 } from '../state/recollectionStore'
|
|
import { removeLogosPageContent } from '../state/recollectionStore'
|
|
|
|
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
|
type TreeNode = 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, {
|
|
...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.position - b.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()}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const ROW_INDENT_PX = 20
|
|
const ROW_GUTTER_PX = 8
|
|
|
|
/**
|
|
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
|
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
|
*/
|
|
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
|
return (
|
|
<div
|
|
role="presentation"
|
|
aria-hidden
|
|
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
|
style={{
|
|
top: top - 5,
|
|
left: left + ROW_GUTTER_PX,
|
|
right: Math.max(indent, ROW_GUTTER_PX),
|
|
}}
|
|
>
|
|
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
|
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
|
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
|
</svg>
|
|
</span>
|
|
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
|
<div
|
|
className={cn(
|
|
'h-0 w-full border-t-2 border-dashed border-primary',
|
|
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
|
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
|
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
|
*/
|
|
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
const navigate = useNavigate()
|
|
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
|
|
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
|
const baseLogos = `${base}/logos`
|
|
|
|
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(() => {
|
|
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)
|
|
if (recollectionId) {
|
|
toDelete.forEach((pageId) => {
|
|
removeLogosPageContent(recollectionId, pageId)
|
|
})
|
|
}
|
|
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])
|
|
if (!node.isOpen) node.open()
|
|
}, [tree, handleTreeChange, node])
|
|
|
|
const hasChildren = (node.children?.length || 0) > 0
|
|
const pageTitle = node.data.title || 'Untitled'
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
|
<div
|
|
className={cn(
|
|
'flex shrink-0 items-center justify-center overflow-hidden',
|
|
'transition-[width,opacity] duration-200 ease-out',
|
|
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
|
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
|
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
|
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
|
)}
|
|
>
|
|
<div
|
|
ref={dragHandle}
|
|
style={{ ...style, paddingLeft: 0 }}
|
|
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
|
className={cn(
|
|
'logos-tree-drag-handle group/drag',
|
|
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
|
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
|
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
|
'text-sidebar-foreground/70',
|
|
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
|
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
'hover:shadow-sm',
|
|
'active:scale-[0.97] active:cursor-grabbing',
|
|
'cursor-grab outline-none',
|
|
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
|
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
|
node.state.isDragging &&
|
|
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<GripVertical
|
|
className={cn(
|
|
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
|
node.state.isDragging && 'opacity-100'
|
|
)}
|
|
strokeWidth={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{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" />
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
|
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
|
)}
|
|
onClick={handleSelect}
|
|
>
|
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
|
|
<div className="min-w-0 flex-1">
|
|
{node.state.isEditing ? (
|
|
<EditableTitle
|
|
title={node.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="block truncate select-none">{pageTitle}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
|
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
|
const { pathname } = useLocation()
|
|
const { activePageId } = useRecollectionSidebar()
|
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
|
const baseLogos = `${base}/logos`
|
|
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
|
const level = node.level
|
|
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
|
|
|
|
return (
|
|
<div
|
|
ref={innerRef}
|
|
{...attrs}
|
|
style={{
|
|
...attrs.style,
|
|
paddingLeft: `${indentPx}px`,
|
|
}}
|
|
className={cn(
|
|
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
|
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
|
node.state.isDragging && 'cursor-grabbing opacity-45',
|
|
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
|
node.state.willReceiveDrop &&
|
|
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
|
)}
|
|
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
|
>
|
|
{level > 0 && (
|
|
<div
|
|
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
|
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
|
/>
|
|
)}
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function TreeBrowser() {
|
|
const { tree, handleTreeChange } = useRecollectionSidebar()
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
|
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
|
|
|
// 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.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 (react-arborist onMove)
|
|
const handleMove = useCallback(
|
|
({
|
|
dragIds,
|
|
parentId: newParentId,
|
|
index,
|
|
}: {
|
|
dragIds: string[]
|
|
parentId: string | null
|
|
index: number
|
|
}) => {
|
|
const dragId = dragIds[0]
|
|
if (!dragId) return
|
|
|
|
const dragged = tree.find((p) => p.id === dragId)
|
|
if (!dragged) return
|
|
|
|
const oldParentId = dragged.parentId
|
|
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
|
|
|
const newSiblings = tree
|
|
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
|
.sort((a, b) => a.position - b.position)
|
|
newSiblings.splice(index, 0, moved)
|
|
|
|
const updates = new Map<string, LogosPageMeta>()
|
|
newSiblings.forEach((p, i) => {
|
|
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
|
})
|
|
|
|
if (oldParentId !== newParentId) {
|
|
tree
|
|
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
|
.sort((a, b) => a.position - b.position)
|
|
.forEach((p, i) => {
|
|
updates.set(p.id, { ...p, position: i })
|
|
})
|
|
}
|
|
|
|
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
|
},
|
|
[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('')
|
|
}, [])
|
|
|
|
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
|
|
// varying key counts on the props object trigger "dependency array changed size" warnings.
|
|
const arboristTreeProps = useMemo(
|
|
() => ({
|
|
data: filteredTree,
|
|
idAccessor: 'id' as const,
|
|
childrenAccessor: 'children' as const,
|
|
width: '100%' as const,
|
|
height: Math.max(treeContainerHeight, 100),
|
|
rowHeight: 32,
|
|
indent: ROW_INDENT_PX,
|
|
renderRow: TreeRow,
|
|
initialOpenState,
|
|
onMove: handleMove,
|
|
disableDrag: Boolean(searchQuery),
|
|
disableDrop: Boolean(searchQuery),
|
|
className: 'react-arborist-tree',
|
|
renderCursor: LogosDropCursor,
|
|
children: LogosTreeNode,
|
|
}),
|
|
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
|
)
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 h-full">
|
|
{/* Search Toolbar */}
|
|
<div className="flex items-center gap-1.5">
|
|
<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 ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
|
<Tree ref={treeRef} {...arboristTreeProps} />
|
|
</div>
|
|
</div>
|
|
)
|
|
} |