feat: add tree library view system with grid/list/table modes and sorting
Implements Issue #34 - Tree Library Full View System Backend Changes: - Add sort_by parameter to GET /api/v1/trees endpoint - Support 6 sorting options: usage_count, updated_at, created_at, name, name_desc, version - Maintain backward compatibility (defaults to usage_count) - Add comprehensive test for sorting functionality - All 104 backend tests passing Frontend Changes: - Create ViewToggle component for switching between Grid/List/Table views - Create SortDropdown component for 6 sort options - Create TreeGridView component (extracted from TreeLibraryPage) - Create TreeListView component (compact row-based layout) - Create TreeTableView component (sortable table with columns) - Update userPreferencesStore with view and sort preferences - Update TreeFilters type to include sort_by parameter - Update TreeLibraryPage to integrate new components - View and sort preferences persist to localStorage Features: - Grid view: Best for discovery (default) - List view: Best for quick scanning - Table view: Best for sorting and comparison - Responsive design: Mobile/tablet/desktop optimized - Table view hides columns responsively - Sortable table headers with visual indicators - Smooth transitions and hover effects - No layout shift when switching views Testing: - Backend: 104/104 tests pass - Frontend: Build successful, no TypeScript errors - All existing functionality preserved Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
44
frontend/src/components/library/SortDropdown.tsx
Normal file
44
frontend/src/components/library/SortDropdown.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ArrowUpDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: SortBy
|
||||
onChange: (sortBy: SortBy) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sortOptions: { value: SortBy; label: string }[] = [
|
||||
{ value: 'usage_count', label: 'Most Used' },
|
||||
{ value: 'updated_at', label: 'Recently Updated' },
|
||||
{ value: 'created_at', label: 'Recently Created' },
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
{ value: 'name_desc', label: 'Name (Z-A)' },
|
||||
{ value: 'version', label: 'Version Number' },
|
||||
]
|
||||
|
||||
export function SortDropdown({ value, onChange, className }: SortDropdownProps) {
|
||||
return (
|
||||
<div className={cn('relative inline-flex items-center', className)}>
|
||||
<span className="mr-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sort:</span>
|
||||
</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as SortBy)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-1.5 text-sm',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/library/TreeGridView.tsx
Normal file
110
frontend/src/components/library/TreeGridView.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2 } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
|
||||
interface TreeGridViewProps {
|
||||
trees: TreeListItem[]
|
||||
onStartSession: (treeId: string) => void
|
||||
onTagClick: (tag: string) => void
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
}
|
||||
|
||||
export function TreeGridView({
|
||||
trees,
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onDeleteTree,
|
||||
}: TreeGridViewProps) {
|
||||
const { canEditTree, canDeleteTree } = usePermissions()
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<TagBadges tags={tree.tags} maxVisible={3} onTagClick={onTagClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{canDeleteTree() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
title="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/library/TreeListView.tsx
Normal file
102
frontend/src/components/library/TreeListView.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
|
||||
interface TreeListViewProps {
|
||||
trees: TreeListItem[]
|
||||
onStartSession: (treeId: string) => void
|
||||
onTagClick: (tag: string) => void
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
}
|
||||
|
||||
export function TreeListView({
|
||||
trees,
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
}: TreeListViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
{/* Left: Name and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-card-foreground truncate">{tree.name}</h3>
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Center: Category and Tags */}
|
||||
<div className="hidden lg:flex items-center gap-2 min-w-0" style={{ maxWidth: '300px' }}>
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground whitespace-nowrap">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<div className="min-w-0">
|
||||
<TagBadges tags={tree.tags} maxVisible={2} onTagClick={onTagClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Metadata and Actions */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="hidden sm:flex flex-col items-end text-xs text-muted-foreground">
|
||||
<span>v{tree.version}</span>
|
||||
<span>{tree.usage_count} uses</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/components/library/TreeTableView.tsx
Normal file
208
frontend/src/components/library/TreeTableView.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
|
||||
interface TreeTableViewProps {
|
||||
trees: TreeListItem[]
|
||||
onStartSession: (treeId: string) => void
|
||||
onTagClick: (tag: string) => void
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onSortChange?: (sortBy: string) => void
|
||||
}
|
||||
|
||||
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
||||
|
||||
export function TreeTableView({
|
||||
trees,
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onSortChange,
|
||||
}: TreeTableViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
const newDirection =
|
||||
sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc'
|
||||
setSortColumn(column)
|
||||
setSortDirection(newDirection)
|
||||
|
||||
// Map to API sort values
|
||||
const sortMap: Record<string, string> = {
|
||||
name_asc: 'name',
|
||||
name_desc: 'name_desc',
|
||||
usage_asc: 'usage_count',
|
||||
usage_desc: 'usage_count',
|
||||
updated_asc: 'updated_at',
|
||||
updated_desc: 'updated_at',
|
||||
version_asc: 'version',
|
||||
version_desc: 'version',
|
||||
}
|
||||
|
||||
const sortKey = `${column}_${newDirection}`
|
||||
const apiSort = sortMap[sortKey] || 'usage_count'
|
||||
onSortChange?.(apiSort)
|
||||
}
|
||||
|
||||
const SortIcon = ({ column }: { column: SortColumn }) => {
|
||||
if (sortColumn !== column) return null
|
||||
return sortDirection === 'asc' ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Name
|
||||
<SortIcon column="name" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="hidden lg:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Category
|
||||
<SortIcon column="category" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="hidden xl:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Tags
|
||||
</th>
|
||||
<th
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('version')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Ver.
|
||||
<SortIcon column="version" />
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('usage')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Uses
|
||||
<SortIcon column="usage" />
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('updated')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Updated
|
||||
<SortIcon column="updated" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card">
|
||||
{trees.map((tree) => (
|
||||
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-card-foreground truncate max-w-[200px]">
|
||||
{tree.name}
|
||||
</span>
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-muted-foreground">
|
||||
<span className="truncate block max-w-[250px]">
|
||||
{tree.description || 'No description'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden lg:table-cell px-4 py-3">
|
||||
{tree.category_info && (
|
||||
<span className="inline-block rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden xl:table-cell px-4 py-3">
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<TagBadges tags={tree.tags} maxVisible={2} onTagClick={onTagClick} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
v{tree.version}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
{tree.usage_count}
|
||||
</td>
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(tree.updated_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/library/ViewToggle.tsx
Normal file
56
frontend/src/components/library/ViewToggle.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { LayoutGrid, List, Table } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ViewMode = 'grid' | 'list' | 'table'
|
||||
|
||||
interface ViewToggleProps {
|
||||
view: ViewMode
|
||||
onChange: (view: ViewMode) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 rounded-md border border-input p-1', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('grid')}
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'grid'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Grid view"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('list')}
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="List view"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('table')}
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'table'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Table view"
|
||||
>
|
||||
<Table className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Plus, Pencil, Globe, Lock, X, Trash2, FolderOpen } from 'lucide-react'
|
||||
import { Plus, X, FolderOpen } from 'lucide-react'
|
||||
import { treesApi, categoriesApi, foldersApi } from '@/api'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { FolderSidebar } from '@/components/library/FolderSidebar'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { AddToFolderMenu } from '@/components/library/AddToFolderMenu'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function TreeLibraryPage() {
|
||||
const { canCreateTrees, canEditTree, canDeleteTree } = usePermissions()
|
||||
const { canCreateTrees } = usePermissions()
|
||||
const navigate = useNavigate()
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
||||
@@ -22,7 +27,10 @@ export function TreeLibraryPage() {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// View preferences from store
|
||||
const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } =
|
||||
useUserPreferencesStore()
|
||||
|
||||
// Folder modal state
|
||||
const [folderModalOpen, setFolderModalOpen] = useState(false)
|
||||
@@ -48,7 +56,7 @@ export function TreeLibraryPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId])
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy])
|
||||
|
||||
// Load folders on mount and listen for changes
|
||||
useEffect(() => {
|
||||
@@ -60,20 +68,20 @@ export function TreeLibraryPage() {
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [treesData, categoriesData] = await Promise.all([
|
||||
treesApi.list({
|
||||
category_id: selectedCategoryId || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||
folder_id: selectedFolderId || undefined,
|
||||
sort_by: treeLibrarySortBy,
|
||||
}),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
setTrees(treesData)
|
||||
setCategories(categoriesData)
|
||||
} catch (err) {
|
||||
setError('Failed to load trees')
|
||||
toast.error('Failed to load trees')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -86,12 +94,11 @@ export function TreeLibraryPage() {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const results = await treesApi.search(searchQuery)
|
||||
setTrees(results)
|
||||
} catch (err) {
|
||||
setError('Search failed')
|
||||
toast.error('Failed to search trees')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -138,9 +145,10 @@ export function TreeLibraryPage() {
|
||||
await treesApi.delete(treeToDelete.id)
|
||||
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
||||
window.dispatchEvent(new Event('folder-changed'))
|
||||
toast.success(`Tree "${treeToDelete.name}" deleted successfully`)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tree:', err)
|
||||
setError('Failed to delete tree')
|
||||
toast.error('Failed to delete tree')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteConfirm(false)
|
||||
@@ -191,59 +199,67 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row">
|
||||
{/* Mobile folder button */}
|
||||
<button
|
||||
onClick={() => setMobileFolderOpen(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input px-3 py-2 text-sm font-medium md:hidden',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
selectedFolderId && 'border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Folders
|
||||
</button>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Mobile folder button */}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
onClick={() => setMobileFolderOpen(true)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
'flex items-center gap-2 rounded-md border border-input px-3 py-2 text-sm font-medium md:hidden',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
selectedFolderId && 'border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Folders
|
||||
</button>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({cat.tree_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({cat.tree_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters */}
|
||||
@@ -295,11 +311,6 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -311,91 +322,49 @@ export function TreeLibraryPage() {
|
||||
{(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<TagBadges tags={tree.tags} maxVisible={3} onTagClick={handleTagClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{canDeleteTree() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
title="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{treeLibraryView === 'grid' && (
|
||||
<TreeGridView
|
||||
trees={trees}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleCreateFolder}
|
||||
onDeleteTree={(tree) => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'list' && (
|
||||
<TreeListView
|
||||
trees={trees}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleCreateFolder}
|
||||
onDeleteTree={(tree) => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'table' && (
|
||||
<TreeTableView
|
||||
trees={trees}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleCreateFolder}
|
||||
onDeleteTree={(tree) => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onSortChange={(sortBy) => {
|
||||
setTreeLibrarySortBy(
|
||||
sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,16 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
type ExportFormat = 'markdown' | 'text' | 'html'
|
||||
type TreeLibraryView = 'grid' | 'list' | 'table'
|
||||
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
|
||||
interface UserPreferencesState {
|
||||
defaultExportFormat: ExportFormat
|
||||
setDefaultExportFormat: (format: ExportFormat) => void
|
||||
treeLibraryView: TreeLibraryView
|
||||
setTreeLibraryView: (view: TreeLibraryView) => void
|
||||
treeLibrarySortBy: TreeSortBy
|
||||
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -13,6 +19,10 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
(set) => ({
|
||||
defaultExportFormat: 'markdown',
|
||||
setDefaultExportFormat: (format) => set({ defaultExportFormat: format }),
|
||||
treeLibraryView: 'grid',
|
||||
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
|
||||
treeLibrarySortBy: 'usage_count',
|
||||
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface TreeFilters {
|
||||
is_active?: boolean
|
||||
author_id?: string
|
||||
is_public?: boolean
|
||||
sort_by?: 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user