bookwiz.io / components / FileExplorer.tsx
FileExplorer.tsx
Raw
'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>
  )
}