BrandLogo gradient, EmptyStateIllustrations SVGs, categoryColors, landing page, brand SVG assets, and all remaining files. Warning #eab308 → #fbbf24 (amber). categoryColors deduped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
284 lines
9.5 KiB
TypeScript
284 lines
9.5 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { X } from 'lucide-react'
|
|
import { foldersApi } from '@/api/folders'
|
|
import type { FolderListItem, FolderCreate, FolderUpdate } from '@/types'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import { Button } from '@/components/ui/Button'
|
|
|
|
// Predefined color options
|
|
const FOLDER_COLORS = [
|
|
'#6366f1', // Indigo (default)
|
|
'#8b5cf6', // Violet
|
|
'#ec4899', // Pink
|
|
'#ef4444', // Red
|
|
'#60a5fa', // Orange
|
|
'#fbbf24', // Yellow
|
|
'#22c55e', // Green
|
|
'#14b8a6', // Teal
|
|
'#3b82f6', // Blue
|
|
'#64748b', // Slate
|
|
]
|
|
|
|
interface FolderEditModalProps {
|
|
folder: FolderListItem | null // null for create mode
|
|
parentId?: string | null // Pre-selected parent for creating subfolders
|
|
folders: FolderListItem[] // All folders for parent dropdown
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSave: () => void
|
|
}
|
|
|
|
// Get all descendant IDs of a folder (to prevent cycles)
|
|
function getDescendantIds(folders: FolderListItem[], folderId: string): Set<string> {
|
|
const descendants = new Set<string>()
|
|
const children = folders.filter((f) => f.parent_id === folderId)
|
|
children.forEach((child) => {
|
|
descendants.add(child.id)
|
|
getDescendantIds(folders, child.id).forEach((id) => descendants.add(id))
|
|
})
|
|
return descendants
|
|
}
|
|
|
|
// Calculate folder depth
|
|
function getFolderDepth(folders: FolderListItem[], folderId: string | null): number {
|
|
if (!folderId) return 0
|
|
const folder = folders.find((f) => f.id === folderId)
|
|
if (!folder || !folder.parent_id) return 1
|
|
return 1 + getFolderDepth(folders, folder.parent_id)
|
|
}
|
|
|
|
// Get indented folder name for dropdown display
|
|
function getIndentedName(folders: FolderListItem[], folderId: string): string {
|
|
const depth = getFolderDepth(folders, folderId)
|
|
const folder = folders.find((f) => f.id === folderId)
|
|
const indent = ' '.repeat(depth - 1)
|
|
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
|
|
}
|
|
|
|
export function FolderEditModal({
|
|
folder,
|
|
parentId: initialParentId,
|
|
folders,
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
}: FolderEditModalProps) {
|
|
const [name, setName] = useState('')
|
|
const [color, setColor] = useState(FOLDER_COLORS[0])
|
|
const [parentId, setParentId] = useState<string | null>(null)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const isEditMode = folder !== null
|
|
|
|
// Build list of valid parent options
|
|
const parentOptions = useMemo(() => {
|
|
// Can't be own parent, can't create cycles
|
|
const invalidIds = new Set<string>()
|
|
|
|
if (folder) {
|
|
// Exclude self and all descendants
|
|
invalidIds.add(folder.id)
|
|
getDescendantIds(folders, folder.id).forEach((id) => invalidIds.add(id))
|
|
}
|
|
|
|
// Filter to valid parents only (max depth 2 so that new folder is at depth 3)
|
|
return folders
|
|
.filter((f) => !invalidIds.has(f.id))
|
|
.filter((f) => {
|
|
const depth = getFolderDepth(folders, f.id)
|
|
// If creating new folder, parent can be at depth 1 or 2 (new folder at 2 or 3)
|
|
// If editing, we need to check if moving would exceed depth limit
|
|
if (folder) {
|
|
// Get max depth of folder's subtree
|
|
const getMaxSubtreeDepth = (folderId: string): number => {
|
|
const children = folders.filter((c) => c.parent_id === folderId)
|
|
if (children.length === 0) return 0
|
|
return 1 + Math.max(...children.map((c) => getMaxSubtreeDepth(c.id)))
|
|
}
|
|
const subtreeDepth = getMaxSubtreeDepth(folder.id)
|
|
// New parent depth + 1 (for this folder) + subtree must be <= 3
|
|
return depth + 1 + subtreeDepth <= 3
|
|
}
|
|
return depth < 3 // Can add child to folders at depth 1 or 2
|
|
})
|
|
.sort((a, b) => {
|
|
// Sort by hierarchy for better UX
|
|
const aPath = getPath(folders, a.id)
|
|
const bPath = getPath(folders, b.id)
|
|
return aPath.localeCompare(bPath)
|
|
})
|
|
}, [folder, folders])
|
|
|
|
// Get path string for sorting
|
|
function getPath(allFolders: FolderListItem[], folderId: string): string {
|
|
const f = allFolders.find((x) => x.id === folderId)
|
|
if (!f) return ''
|
|
if (!f.parent_id) return f.name
|
|
return getPath(allFolders, f.parent_id) + '/' + f.name
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (folder) {
|
|
setName(folder.name)
|
|
setColor(folder.color)
|
|
setParentId(folder.parent_id || null)
|
|
} else {
|
|
setName('')
|
|
setColor(FOLDER_COLORS[0])
|
|
setParentId(initialParentId || null)
|
|
}
|
|
}, [folder, initialParentId, isOpen])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!name.trim()) {
|
|
toast.error('Folder name is required')
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
if (isEditMode && folder) {
|
|
const updateData: FolderUpdate = { name, color }
|
|
// Only include parent_id if it changed
|
|
if (parentId !== folder.parent_id) {
|
|
updateData.parent_id = parentId
|
|
}
|
|
await foldersApi.update(folder.id, updateData)
|
|
toast.success('Folder updated successfully')
|
|
} else {
|
|
const createData: FolderCreate = { name, color }
|
|
if (parentId) {
|
|
createData.parent_id = parentId
|
|
}
|
|
await foldersApi.create(createData)
|
|
toast.success('Folder created successfully')
|
|
}
|
|
onSave()
|
|
onClose()
|
|
// Dispatch event to refresh folder list
|
|
window.dispatchEvent(new Event('folder-changed'))
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error && 'response' in err
|
|
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
|
: undefined
|
|
toast.error(errorMessage || 'Failed to save folder')
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-black/80" onClick={onClose} />
|
|
|
|
{/* Modal */}
|
|
<div className="relative z-10 w-full max-w-md bg-card border border-border rounded-2xl p-6 shadow-lg">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-foreground">
|
|
{isEditMode ? 'Edit Folder' : initialParentId ? 'Create Subfolder' : 'Create Folder'}
|
|
</h2>
|
|
<button onClick={onClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent/50 hover:text-foreground">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Name input */}
|
|
<div className="mb-4">
|
|
<label htmlFor="folder-name" className="block text-sm font-medium text-foreground">
|
|
Name
|
|
</label>
|
|
<input
|
|
id="folder-name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g., Citrix Issues"
|
|
className={cn(
|
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
|
'bg-card text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
|
'border-border'
|
|
)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Parent folder dropdown */}
|
|
<div className="mb-4">
|
|
<label htmlFor="folder-parent" className="block text-sm font-medium text-foreground">
|
|
Parent Folder
|
|
</label>
|
|
<select
|
|
id="folder-parent"
|
|
value={parentId || ''}
|
|
onChange={(e) => setParentId(e.target.value || null)}
|
|
className={cn(
|
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
|
'bg-card text-foreground',
|
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
|
'border-border'
|
|
)}
|
|
>
|
|
<option value="">None (root level)</option>
|
|
{parentOptions.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{getIndentedName(folders, f.id)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Folders can be nested up to 3 levels deep.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Color picker */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-foreground">Color</label>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{FOLDER_COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setColor(c)}
|
|
className={cn(
|
|
'h-8 w-8 rounded-full transition-transform',
|
|
color === c && 'ring-2 ring-offset-2 ring-offset-black ring-white/50 scale-110'
|
|
)}
|
|
style={{ backgroundColor: c }}
|
|
title={c}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={onClose}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
loading={isSubmitting}
|
|
>
|
|
{isEditMode ? 'Save Changes' : 'Create Folder'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FolderEditModal
|