* chore: run Tailwind v4 upgrade tool (Phase 1) - 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> * chore: switch from @tailwindcss/postcss to @tailwindcss/vite (Phase 2) - Replaced @tailwindcss/postcss with @tailwindcss/vite plugin - Deleted postcss.config.js (no longer needed) - Tailwind now runs as a native Vite plugin for faster HMR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: convert to OKLCH colors, move keyframes into @theme (Phase 3-4) - Replaced all HSL color indirection with direct OKLCH values in @theme - Moved all keyframes inside @theme block (v4 pattern) - Eliminated hsl(var(--x)) double-indirection across 17 component files - Replaced hsl() inline styles with var(--color-*) theme references - Cleaned up redundant rdp-* utility blocks - Fixed @custom-variant dark syntax to use :where() - Added sidebar/glass/shadow vars as OKLCH in :root Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { FolderPlus, Check, Plus } from 'lucide-react'
|
|
import { foldersApi } from '@/api/folders'
|
|
import type { FolderListItem } from '@/types'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface AddToFolderMenuProps {
|
|
treeId: string
|
|
onFolderCreated?: () => void
|
|
}
|
|
|
|
export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProps) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [folders, setFolders] = useState<FolderListItem[]>([])
|
|
const [treeFolderIds, setTreeFolderIds] = useState<Set<string>>(new Set())
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadFoldersAndAssignments()
|
|
}
|
|
}, [isOpen, treeId])
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
}
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [isOpen])
|
|
|
|
const loadFoldersAndAssignments = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const foldersData = await foldersApi.list()
|
|
setFolders(foldersData)
|
|
|
|
// Check which folders contain this tree
|
|
const folderIds = new Set<string>()
|
|
for (const folder of foldersData) {
|
|
try {
|
|
const treeIds = await foldersApi.getTreeIds(folder.id)
|
|
if (treeIds.includes(treeId)) {
|
|
folderIds.add(folder.id)
|
|
}
|
|
} catch {
|
|
// Ignore errors for individual folder checks
|
|
}
|
|
}
|
|
setTreeFolderIds(folderIds)
|
|
} catch (err) {
|
|
console.error('Failed to load folders:', err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const toggleFolder = async (folderId: string) => {
|
|
try {
|
|
if (treeFolderIds.has(folderId)) {
|
|
await foldersApi.removeTree(folderId, treeId)
|
|
setTreeFolderIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(folderId)
|
|
return next
|
|
})
|
|
} else {
|
|
await foldersApi.addTree(folderId, treeId)
|
|
setTreeFolderIds((prev) => new Set([...prev, folderId]))
|
|
}
|
|
// Dispatch event to refresh folder counts
|
|
window.dispatchEvent(new Event('folder-changed'))
|
|
} catch (err) {
|
|
console.error('Failed to toggle folder:', err)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div ref={menuRef} className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setIsOpen(!isOpen)
|
|
}}
|
|
className={cn(
|
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
title="Add to folder"
|
|
aria-label="Add to folder"
|
|
>
|
|
<FolderPlus className="h-4 w-4" />
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div
|
|
className={cn(
|
|
'absolute right-0 top-full z-20 mt-1 w-48 rounded-md border border-border',
|
|
'bg-card backdrop-blur-xs py-1 shadow-lg'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">Loading...</div>
|
|
) : folders.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">No folders yet</div>
|
|
) : (
|
|
folders.map((folder) => (
|
|
<button
|
|
key={folder.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleFolder(folder.id)
|
|
}}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<div
|
|
className="h-3 w-3 rounded-sm"
|
|
style={{ backgroundColor: folder.color }}
|
|
/>
|
|
<span className="flex-1 truncate text-left">{folder.name}</span>
|
|
{treeFolderIds.has(folder.id) && (
|
|
<Check className="h-4 w-4 text-foreground" />
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
|
|
<div className="border-t border-border my-1" />
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setIsOpen(false)
|
|
onFolderCreated?.()
|
|
}}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Create new folder
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AddToFolderMenu
|