'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { FileSystemItem } from '@/lib/types/database'
import {
IoFolderOutline,
IoDocumentTextOutline,
IoCreateOutline,
IoTrashOutline,
IoEllipsisVerticalOutline,
IoCloseOutline
} from 'react-icons/io5'
interface FileExplorerProps {
files: (FileSystemItem & { children?: FileSystemItem[] })[]
onFileSelect: (file: FileSystemItem) => void
selectedFile: FileSystemItem | null
onCreateFile?: (parentId?: string) => void
onCreateFolder?: (parentId?: string) => void
onRename?: (item: FileSystemItem) => void
onDelete?: (item: FileSystemItem) => void
onToggleExpanded?: (item: FileSystemItem) => void
onMove?: (item: FileSystemItem, newParentId?: string) => void
bookId?: string
loading?: boolean
}
interface FileTreeItemProps {
item: FileSystemItem & { children?: FileSystemItem[] }
level: number
onFileSelect: (file: FileSystemItem) => void
selectedFile: FileSystemItem | null
onToggleExpanded: (id: string) => void
onContextMenu?: (item: FileSystemItem, x: number, y: number) => void
onMobileActionMenu?: (item: FileSystemItem) => void
draggedItem: FileSystemItem | null
onDragStart: (item: FileSystemItem) => void
onDragEnd: () => void
onDrop: (targetItem: FileSystemItem, draggedItem: FileSystemItem) => void
dragOverItem: string | null
onDragOver: (itemId: string | null) => void
}
interface ContextMenuProps {
x: number
y: number
item: FileSystemItem
onClose: () => void
onCreateFile?: (parentId?: string) => void
onCreateFolder?: (parentId?: string) => void
onRename?: (item: FileSystemItem) => void
onDelete?: (item: FileSystemItem) => void
}
interface MobileActionMenuProps {
item: FileSystemItem
onClose: () => void
onCreateFile?: (parentId?: string) => void
onCreateFolder?: (parentId?: string) => void
onRename?: (item: FileSystemItem) => void
onDelete?: (item: FileSystemItem) => void
}
function MobileActionMenu({ item, onClose, onCreateFile, onCreateFolder, onRename, onDelete }: MobileActionMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const menuItems = [
...(item.type === 'folder' ? [
{
label: 'New File',
icon: IoDocumentTextOutline,
action: () => {
onCreateFile?.(item.id)
onClose()
}
},
{
label: 'New Folder',
icon: IoFolderOutline,
action: () => {
onCreateFolder?.(item.id)
onClose()
}
}
] : []),
{
label: 'Rename',
icon: IoCreateOutline,
action: () => {
onRename?.(item)
onClose()
}
},
{
label: 'Delete',
icon: IoTrashOutline,
action: () => {
onDelete?.(item)
onClose()
},
danger: true
}
]
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end justify-center p-4">
<div
ref={menuRef}
className="bg-slate-800/95 backdrop-blur-sm border border-slate-700/50 rounded-t-2xl shadow-2xl w-full max-w-sm"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/50">
<h3 className="text-slate-200 font-medium text-sm truncate">
{item.name}
</h3>
<button
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-all duration-200"
>
<IoCloseOutline className="w-5 h-5" />
</button>
</div>
{/* Menu Items */}
<div className="py-2">
{menuItems.map((menuItem, index) => (
<button
key={index}
onClick={menuItem.action}
className={`w-full px-4 py-3 text-sm text-left flex items-center gap-3 hover:bg-slate-700/50 transition-all duration-200 ${
menuItem.danger ? 'text-red-300 hover:text-red-200' : 'text-slate-200'
}`}
>
<menuItem.icon className="w-5 h-5" />
{menuItem.label}
</button>
))}
</div>
{/* Cancel Button */}
<div className="p-4 border-t border-slate-700/50">
<button
onClick={onClose}
className="w-full py-3 text-slate-300 hover:text-slate-200 text-sm font-medium transition-all duration-200"
>
Cancel
</button>
</div>
</div>
</div>
)
}
function ContextMenu({ x, y, item, onClose, onCreateFile, onCreateFolder, onRename, onDelete }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const menuItems = [
...(item.type === 'folder' ? [
{
label: 'New File',
icon: IoDocumentTextOutline,
action: () => {
onCreateFile?.(item.id)
onClose()
}
},
{
label: 'New Folder',
icon: IoFolderOutline,
action: () => {
onCreateFolder?.(item.id)
onClose()
}
}
] : []),
{
label: 'Rename',
icon: IoCreateOutline,
action: () => {
onRename?.(item)
onClose()
}
},
{
label: 'Delete',
icon: IoTrashOutline,
action: () => {
onDelete?.(item)
onClose()
},
danger: true
}
]
return (
<div
ref={menuRef}
className="fixed z-50 bg-slate-800/90 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg py-1 min-w-[160px]"
style={{ left: x, top: y }}
>
{menuItems.map((menuItem, index) => (
<button
key={index}
onClick={menuItem.action}
className={`w-full px-3 py-2 text-sm text-left flex items-center gap-2 hover:bg-slate-700/50 transition-all duration-200 ${
menuItem.danger ? 'text-red-300 hover:text-red-200' : 'text-slate-200'
}`}
>
<menuItem.icon className="w-4 h-4" />
{menuItem.label}
</button>
))}
</div>
)
}
function FileTreeItem({
item,
level,
onFileSelect,
selectedFile,
onToggleExpanded,
onContextMenu,
onMobileActionMenu,
draggedItem,
onDragStart,
onDragEnd,
onDrop,
dragOverItem,
onDragOver
}: FileTreeItemProps) {
const isSelected = selectedFile?.id === item.id
const isFolder = item.type === 'folder'
const hasChildren = item.children && item.children.length > 0
const isDragging = draggedItem?.id === item.id
const isDragOver = dragOverItem === item.id
const isValidDropTarget = isFolder && draggedItem && draggedItem.id !== item.id
// Check if device is mobile
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768
const handleClick = () => {
if (isFolder) {
onToggleExpanded(item.id)
} else {
onFileSelect(item)
}
}
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onContextMenu?.(item, e.clientX, e.clientY)
}
const handleMobileActionMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onMobileActionMenu?.(item)
}
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation()
onDragStart(item)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', item.id)
e.dataTransfer.setData('application/json', JSON.stringify({
id: item.id,
name: item.name,
type: item.type
}))
}
const handleDragEnd = (e: React.DragEvent) => {
e.stopPropagation()
onDragEnd()
}
const handleDragOver = (e: React.DragEvent) => {
if (!isValidDropTarget) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
onDragOver(item.id)
}
const handleDragEnter = (e: React.DragEvent) => {
if (!isValidDropTarget) return
e.preventDefault()
e.stopPropagation()
onDragOver(item.id)
}
const handleDragLeave = (e: React.DragEvent) => {
if (!isValidDropTarget) return
e.preventDefault()
e.stopPropagation()
// Only clear drag over if we're actually leaving this element
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
onDragOver(null)
}
}
const handleDrop = (e: React.DragEvent) => {
if (!isValidDropTarget || !draggedItem) return
e.preventDefault()
e.stopPropagation()
onDrop(item, draggedItem)
onDragOver(null)
}
return (
<div>
<div
className={`flex items-center py-1.5 px-2 rounded-lg transition-all duration-200 ${
isSelected
? 'bg-gradient-to-r from-teal-500/20 to-teal-600/20 text-teal-400'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/30'
} ${
isDragging ? 'opacity-50' : ''
} ${
isDragOver && isValidDropTarget ? 'bg-blue-500/20 border-l-2 border-blue-400' : ''
}`}
style={{ paddingLeft: `${(level * 16) + 8}px` }}
onClick={handleClick}
onContextMenu={handleContextMenu}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isFolder && (
<span className="mr-1 text-slate-400 text-xs">
{item.expanded ? '▼' : '▶'}
</span>
)}
<span className="mr-2">
{isFolder ? (
<div className="w-5 h-5 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<IoFolderOutline className="w-3 h-3 text-white" />
</div>
) : (
<div className="w-5 h-5 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center">
<IoDocumentTextOutline className="w-3 h-3 text-white" />
</div>
)}
</span>
<span className="truncate flex-1 text-sm">{item.name}</span>
{/* Mobile: Show action button always, Desktop: Show on hover */}
<button
className={`p-1 transition-all duration-200 ${
isMobile()
? 'opacity-100 text-slate-400 hover:text-slate-200 hover:bg-slate-600/50 rounded-lg bg-slate-700/20'
: 'opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-200 hover:bg-slate-600/50 rounded-lg'
}`}
onClick={isMobile() ? handleMobileActionMenu : handleContextMenu}
>
<IoEllipsisVerticalOutline className="w-3 h-3" />
</button>
</div>
{isFolder && item.expanded && hasChildren && (
<div>
{item.children!.map((child) => (
<FileTreeItem
key={child.id}
item={child}
level={level + 1}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
onToggleExpanded={onToggleExpanded}
onContextMenu={onContextMenu}
onMobileActionMenu={onMobileActionMenu}
draggedItem={draggedItem}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDrop={onDrop}
dragOverItem={dragOverItem}
onDragOver={onDragOver}
/>
))}
</div>
)}
</div>
)
}
export default function FileExplorer({
files,
onFileSelect,
selectedFile,
onCreateFile,
onCreateFolder,
onRename,
onDelete,
onToggleExpanded: externalOnToggleExpanded,
onMove,
bookId,
loading = false
}: FileExplorerProps) {
const [fileState, setFileState] = useState<(FileSystemItem & { children?: FileSystemItem[] })[]>(files)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: FileSystemItem } | null>(null)
const [mobileActionMenu, setMobileActionMenu] = useState<{ item: FileSystemItem } | null>(null)
const [draggedItem, setDraggedItem] = useState<FileSystemItem | null>(null)
const [dragOverItem, setDragOverItem] = useState<string | null>(null)
const explorerRef = useRef<HTMLDivElement>(null)
// Helper functions for localStorage
const getStorageKey = useCallback(() => `fileexplorer_expanded_${bookId}`, [bookId])
const saveExpandedState = (files: (FileSystemItem & { children?: FileSystemItem[] })[]) => {
if (!bookId) return
const expandedIds: string[] = []
const collectExpanded = (items: (FileSystemItem & { children?: FileSystemItem[] })[]) => {
items.forEach(item => {
if (item.expanded) {
expandedIds.push(item.id)
}
if (item.children) {
collectExpanded(item.children)
}
})
}
collectExpanded(files)
localStorage.setItem(getStorageKey(), JSON.stringify(expandedIds))
}
const loadExpandedState = useCallback((): Set<string> => {
if (!bookId) return new Set()
try {
const stored = localStorage.getItem(getStorageKey())
return new Set(stored ? JSON.parse(stored) : [])
} catch {
return new Set()
}
}, [bookId, getStorageKey])
const applyExpandedState = useCallback((files: (FileSystemItem & { children?: FileSystemItem[] })[], expandedIds: Set<string>): (FileSystemItem & { children?: FileSystemItem[] })[] => {
return files.map(item => ({
...item,
expanded: item.type === 'folder' ? expandedIds.has(item.id) : false,
children: item.children ? applyExpandedState(item.children, expandedIds) : []
}))
}, [])
// Update local state when props change, applying saved expanded state
useEffect(() => {
const expandedIds = loadExpandedState()
const filesWithExpandedState = applyExpandedState(files, expandedIds)
setFileState(filesWithExpandedState)
}, [files, bookId, loadExpandedState, applyExpandedState])
const handleToggleExpanded = (id: string) => {
const updateFiles = (items: (FileSystemItem & { children?: FileSystemItem[] })[]): (FileSystemItem & { children?: FileSystemItem[] })[] => {
return items.map(item => {
if (item.id === id) {
const updatedItem = { ...item, expanded: !item.expanded }
return updatedItem
}
if (item.children) {
return { ...item, children: updateFiles(item.children) }
}
return item
})
}
const newFileState = updateFiles(fileState)
setFileState(newFileState)
// Save expanded state to localStorage
saveExpandedState(newFileState)
}
const handleContextMenu = (item: FileSystemItem, x: number, y: number) => {
setContextMenu({ x, y, item })
}
const closeContextMenu = () => {
setContextMenu(null)
}
const handleMobileActionMenu = (item: FileSystemItem) => {
setMobileActionMenu({ item })
}
const closeMobileActionMenu = () => {
setMobileActionMenu(null)
}
const handleDragStart = (item: FileSystemItem) => {
setDraggedItem(item)
}
const handleDragEnd = () => {
setDraggedItem(null)
setDragOverItem(null)
}
const handleDrop = (targetItem: FileSystemItem, draggedItem: FileSystemItem) => {
if (targetItem.type !== 'folder' || targetItem.id === draggedItem.id) return
onMove?.(draggedItem, targetItem.id)
}
const handleRootDrop = (e: React.DragEvent) => {
if (!draggedItem) return
e.preventDefault()
e.stopPropagation()
// Check if we're dropping on the root (not on any specific item)
const target = e.target as HTMLElement
if (target === explorerRef.current || target.closest('.file-tree-item') === null) {
onMove?.(draggedItem, undefined) // undefined means root level
}
setDragOverItem(null)
}
const handleRootDragOver = (e: React.DragEvent) => {
if (!draggedItem) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
const handleDragOver = (itemId: string | null) => {
setDragOverItem(itemId)
}
if (loading) {
return (
<div className="px-4 py-2 relative min-h-full">
{/* File explorer skeleton */}
{[1, 2, 3, 4, 5, 6, 7, 8].map((index) => (
<div
key={index}
className="flex items-center gap-2 mb-2"
style={{ paddingLeft: `${(index % 3) * 16}px` }}
>
<div
className="w-3 h-3 bg-slate-600/50 rounded animate-pulse"
style={{ animationDelay: `${index * 150}ms` }}
></div>
<div
className="w-24 h-3 bg-slate-600/50 rounded animate-pulse"
style={{ animationDelay: `${index * 150 + 50}ms` }}
></div>
</div>
))}
</div>
)
}
return (
<div
ref={explorerRef}
className="px-4 py-2 relative min-h-full"
onDrop={handleRootDrop}
onDragOver={handleRootDragOver}
>
{fileState.map((item) => (
<FileTreeItem
key={item.id}
item={item}
level={0}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
onToggleExpanded={handleToggleExpanded}
onContextMenu={handleContextMenu}
onMobileActionMenu={handleMobileActionMenu}
draggedItem={draggedItem}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
dragOverItem={dragOverItem}
onDragOver={handleDragOver}
/>
))}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
item={contextMenu.item}
onClose={closeContextMenu}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onRename={onRename}
onDelete={onDelete}
/>
)}
{mobileActionMenu && (
<MobileActionMenu
item={mobileActionMenu.item}
onClose={closeMobileActionMenu}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onRename={onRename}
onDelete={onDelete}
/>
)}
</div>
)
}