feat: Phase 2 — pin/favorite buttons on all library view components
- TreeGridView: star in top-right corner of cards - TreeListView: star at end of each row - TreeTableView: dedicated leftmost Favorite column - All with proper a11y (aria-label), event isolation, loading states Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench } from 'lucide-react'
|
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -13,6 +13,9 @@ interface TreeGridViewProps {
|
|||||||
onFolderCreated: (parentId?: string | null) => void
|
onFolderCreated: (parentId?: string | null) => void
|
||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
pinnedTreeIds?: Set<string>
|
||||||
|
onTogglePin?: (treeId: string) => void
|
||||||
|
pinLoadingTreeIds?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeGridView({
|
export function TreeGridView({
|
||||||
@@ -21,6 +24,9 @@ export function TreeGridView({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
pinnedTreeIds,
|
||||||
|
onTogglePin,
|
||||||
|
pinLoadingTreeIds,
|
||||||
}: TreeGridViewProps) {
|
}: TreeGridViewProps) {
|
||||||
const { canEditTree, canDeleteTree } = usePermissions()
|
const { canEditTree, canDeleteTree } = usePermissions()
|
||||||
|
|
||||||
@@ -29,8 +35,28 @@ export function TreeGridView({
|
|||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<div
|
<div
|
||||||
key={tree.id}
|
key={tree.id}
|
||||||
className="bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||||
>
|
>
|
||||||
|
{onTogglePin && (
|
||||||
|
<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(
|
||||||
|
'absolute top-3 right-3 z-10 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={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
|
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -13,6 +13,9 @@ interface TreeListViewProps {
|
|||||||
onFolderCreated: (parentId?: string | null) => void
|
onFolderCreated: (parentId?: string | null) => void
|
||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
pinnedTreeIds?: Set<string>
|
||||||
|
onTogglePin?: (treeId: string) => void
|
||||||
|
pinLoadingTreeIds?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeListView({
|
export function TreeListView({
|
||||||
@@ -21,6 +24,9 @@ export function TreeListView({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
pinnedTreeIds,
|
||||||
|
onTogglePin,
|
||||||
|
pinLoadingTreeIds,
|
||||||
}: TreeListViewProps) {
|
}: TreeListViewProps) {
|
||||||
const { canEditTree } = usePermissions()
|
const { canEditTree } = usePermissions()
|
||||||
|
|
||||||
@@ -84,6 +90,26 @@ export function TreeListView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onTogglePin && (
|
||||||
|
<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(
|
||||||
|
'shrink-0 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={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onForkTree && (
|
{onForkTree && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
|
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -15,6 +15,9 @@ interface TreeTableViewProps {
|
|||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onSortChange?: (sortBy: string) => void
|
onSortChange?: (sortBy: string) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
pinnedTreeIds?: Set<string>
|
||||||
|
onTogglePin?: (treeId: string) => void
|
||||||
|
pinLoadingTreeIds?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
||||||
@@ -26,6 +29,9 @@ export function TreeTableView({
|
|||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
pinnedTreeIds,
|
||||||
|
onTogglePin,
|
||||||
|
pinLoadingTreeIds,
|
||||||
}: TreeTableViewProps) {
|
}: TreeTableViewProps) {
|
||||||
const { canEditTree } = usePermissions()
|
const { canEditTree } = usePermissions()
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||||
@@ -73,6 +79,11 @@ export function TreeTableView({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-accent/50 sticky top-0 z-10">
|
<thead className="bg-accent/50 sticky top-0 z-10">
|
||||||
<tr className="border-b border-border">
|
<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
|
<th
|
||||||
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
@@ -132,6 +143,28 @@ export function TreeTableView({
|
|||||||
<tbody className="bg-transparent">
|
<tbody className="bg-transparent">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
|
<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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-foreground truncate max-w-[200px]">
|
<span className="font-medium text-foreground truncate max-w-[200px]">
|
||||||
|
|||||||
Reference in New Issue
Block a user