- TreeLibraryPage: split categories into a mount-only fetch so filter changes only re-fetch trees (not categories every time) - Add safeGetItem/safeSetItem/safeRemoveItem helpers in utils.ts to prevent crashes in private browsing or when storage is unavailable - Replace raw localStorage calls in ScratchpadSidebar, TreeNavigationPage, TreeEditorPage, and treeEditorStore with safe wrappers - Add aria-label to 20 icon-only buttons across 8 component files for screen reader accessibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { FolderPlus, Check, Plus } from 'lucide-react'
|
|
import { foldersApi } from '@/api'
|
|
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-white/10 p-1.5 text-white/60',
|
|
'hover:bg-white/10 hover:text-white'
|
|
)}
|
|
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-white/10',
|
|
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<div className="px-3 py-2 text-sm text-white/40">Loading...</div>
|
|
) : folders.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-white/40">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-white/70 hover:bg-white/[0.06] hover:text-white"
|
|
>
|
|
<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-white" />
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
|
|
<div className="border-t border-white/10 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-white/70 hover:bg-white/[0.06] hover:text-white"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Create new folder
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AddToFolderMenu
|