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

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

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

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

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