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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user