- Upgraded tailwindcss v3 → v4.2.1, postcss plugin to @tailwindcss/postcss - Deleted tailwind.config.js, migrated theme to CSS @theme block in index.css - Replaced @tailwind directives with @import 'tailwindcss' - Added @custom-variant dark, @utility blocks for custom utilities - Updated class names across 128 files (shadow-sm → shadow-xs, etc.) - Removed autoprefixer (built into v4) - Added migration plan doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
|
import type { TreeListItem } from '@/types'
|
|
import { TagBadges } from '@/components/common/TagBadges'
|
|
import { cn } from '@/lib/utils'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { getTreeEditorPath } from '@/lib/routing'
|
|
|
|
interface TreeTableViewProps {
|
|
trees: TreeListItem[]
|
|
onStartSession: (treeId: string, treeType?: string) => void
|
|
onTagClick: (tag: string) => void
|
|
onFolderCreated: (parentId?: string | null) => void
|
|
onDeleteTree: (tree: TreeListItem) => void
|
|
onSortChange?: (sortBy: string) => void
|
|
onForkTree?: (treeId: string) => void
|
|
onExportTree?: (treeId: string) => void
|
|
pinnedTreeIds?: Set<string>
|
|
onTogglePin?: (treeId: string) => void
|
|
pinLoadingTreeIds?: Set<string>
|
|
}
|
|
|
|
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
|
|
|
export function TreeTableView({
|
|
trees,
|
|
onStartSession,
|
|
onTagClick,
|
|
onDeleteTree,
|
|
onSortChange,
|
|
onForkTree,
|
|
onExportTree,
|
|
pinnedTreeIds,
|
|
onTogglePin,
|
|
pinLoadingTreeIds,
|
|
}: 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 getSortIcon = (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-2xl border border-border">
|
|
<table className="w-full">
|
|
<thead className="bg-accent/50 sticky top-0 z-10">
|
|
<tr className="border-b border-border">
|
|
{onTogglePin && (
|
|
<th className="w-10 px-2 py-3 text-center">
|
|
<Star size={14} className="inline text-muted-foreground" />
|
|
</th>
|
|
)}
|
|
<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
|
|
{getSortIcon('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
|
|
{getSortIcon('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.
|
|
{getSortIcon('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
|
|
{getSortIcon('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
|
|
{getSortIcon('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-transparent">
|
|
{trees.map((tree) => (
|
|
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
|
|
{onTogglePin && (
|
|
<td className="w-10 px-2 py-3 text-center">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
onTogglePin(tree.id)
|
|
}}
|
|
disabled={pinLoadingTreeIds?.has(tree.id)}
|
|
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
|
className={cn(
|
|
'rounded-md p-1 transition-colors',
|
|
pinnedTreeIds?.has(tree.id)
|
|
? 'text-amber-400 hover:text-amber-300'
|
|
: 'text-muted-foreground/40 hover:text-amber-400',
|
|
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
|
)}
|
|
>
|
|
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-foreground truncate max-w-[200px]">
|
|
{tree.name}
|
|
</span>
|
|
{tree.status === 'draft' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-400/10 px-2 py-0.5 text-xs font-medium text-yellow-400 shrink-0">
|
|
<FileText className="h-3 w-3" />
|
|
Draft
|
|
</span>
|
|
)}
|
|
{tree.tree_type === 'maintenance' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400 shrink-0">
|
|
<Wrench className="h-3 w-3" />
|
|
Maintenance
|
|
</span>
|
|
)}
|
|
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
|
|
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
|
Fork
|
|
</span>
|
|
)}
|
|
{tree.is_public ? (
|
|
<span title="Public tree">
|
|
<Globe className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
</span>
|
|
) : (
|
|
<span title="Private tree">
|
|
<Lock className="h-3.5 w-3.5 text-muted-foreground 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-accent px-2 py-0.5 text-xs text-muted-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">
|
|
{onExportTree && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onExportTree(tree.id)}
|
|
className={cn(
|
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
title="Export flow"
|
|
aria-label="Export flow"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{onForkTree && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onForkTree(tree.id)}
|
|
className={cn(
|
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
title="Fork tree"
|
|
aria-label="Fork tree"
|
|
>
|
|
<GitBranch className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
|
<>
|
|
<Link
|
|
to={getTreeEditorPath(tree.id, tree.tree_type)}
|
|
className={cn(
|
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
title="Edit tree"
|
|
aria-label="Edit tree"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteTree(tree)}
|
|
className={cn(
|
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
|
'hover:bg-red-500/20 hover:text-red-400'
|
|
)}
|
|
title="Delete tree"
|
|
aria-label="Delete tree"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => onStartSession(tree.id, tree.tree_type)}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-3 py-1.5 text-xs font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90 whitespace-nowrap'
|
|
)}
|
|
>
|
|
Start
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|