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:
Michael Chihlas
2026-02-07 20:36:20 -05:00
parent 469456c9c9
commit 89e09edc64
11 changed files with 967 additions and 148 deletions

View File

@@ -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>