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

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