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