import React, { useState, useEffect, useCallback, useRef } from 'react'; import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-icons/fa'; import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage'; import { isValidProjectName } from '../../util/projectNameUtil'; import './OpenProjectModal.css'; import { showAlert, showConfirm, showPrompt } from './DialogProvider'; interface OpenProjectModalProps { onClose: () => void; onOpenProject: (projectName: string) => Promise; currentProjectName: string | null; onCreateNewProject: () => void; } type SortField = 'name' | 'createdAt' | 'updatedAt'; type SortDirection = 'asc' | 'desc'; type ViewMode = 'projects' | 'trash'; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; const matchesFilter = (projectName: string, filter: string): boolean => { if (!filter) return true; const normalizedName = projectName.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalizedFilter = filter.toLowerCase().replace(/[^a-z0-9]/g, ''); let nameIndex = 0; for (let i = 0; i < normalizedFilter.length; i++) { const charIndex = normalizedName.indexOf(normalizedFilter[i], nameIndex); if (charIndex === -1) return false; nameIndex = charIndex + 1; } return true; }; const formatDate = (timestamp: number): string => { if (timestamp === 0) return 'Unknown'; const date = new Date(timestamp); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; const OpenProjectModal: React.FC = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => { const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); const [filter, setFilter] = useState(''); const [isClosing, setIsClosing] = useState(false); const mouseDownOnOverlay = useRef(false); const [sortField, setSortField] = useState('updatedAt'); const [sortDirection, setSortDirection] = useState('desc'); const [viewMode, setViewMode] = useState('projects'); const startClose = useCallback(() => setIsClosing(true), []); const handleAnimationEnd = useCallback((e: React.AnimationEvent) => { if (e.target !== e.currentTarget) return; if (!isClosing) return; setIsClosing(false); onClose(); }, [isClosing, onClose]); const fetchProjects = useCallback(async (mode: ViewMode) => { setIsLoading(true); try { const storage = KGProjectStorage.getInstance(); if (mode === 'trash') { await storage.purgeDeletedOlderThan(THIRTY_DAYS_MS); } const list = await storage.listWithMeta(mode === 'trash'); setProjects(list); } catch (error) { console.error('Error loading project list:', error); } finally { setIsLoading(false); } }, []); useEffect(() => { fetchProjects(viewMode); }, [viewMode, fetchProjects]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') startClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [startClose]); const handleSortClick = useCallback((field: SortField) => { if (field === sortField) { setSortDirection(d => (d === 'asc' ? 'desc' : 'asc')); } else { setSortField(field); setSortDirection(field === 'name' ? 'asc' : 'desc'); } }, [sortField]); const filteredProjects = React.useMemo(() => { const filtered = projects.filter(p => matchesFilter(p.name, filter)); filtered.sort((a, b) => { let cmp: number; if (sortField === 'name') { cmp = a.name.localeCompare(b.name); } else { cmp = a[sortField] - b[sortField]; } return sortDirection === 'asc' ? cmp : -cmp; }); return filtered; }, [projects, filter, sortField, sortDirection]); const handleOpen = async (projectName: string) => { await onOpenProject(projectName); startClose(); }; const handleDuplicate = async (e: React.MouseEvent, projectName: string) => { e.stopPropagation(); const newName = await showPrompt('Enter a name for the duplicated project:', projectName); if (!newName || newName.trim() === '') return; const trimmed = newName.trim(); if (!isValidProjectName(trimmed)) { await showAlert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.'); return; } try { const storage = KGProjectStorage.getInstance(); const finalName = await storage.resolveUniqueName(trimmed); await storage.duplicate(projectName, finalName); await onOpenProject(finalName); startClose(); } catch (error) { console.error('Error duplicating project:', error); await showAlert(`Failed to duplicate project: ${error}`); } }; const handleSoftDelete = async (e: React.MouseEvent, projectName: string) => { e.stopPropagation(); try { const storage = KGProjectStorage.getInstance(); await storage.softDelete(projectName); await fetchProjects(viewMode); } catch (error) { console.error('Error deleting project:', error); await showAlert(`Failed to delete project: ${error}`); } }; const handleRestore = async (e: React.MouseEvent, projectName: string) => { e.stopPropagation(); try { const storage = KGProjectStorage.getInstance(); await storage.restore(projectName); await fetchProjects(viewMode); } catch (error) { console.error('Error restoring project:', error); await showAlert(`Failed to restore project: ${error}`); } }; const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => { e.stopPropagation(); const confirmed = await showConfirm( `Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.` ); if (!confirmed) return; try { const storage = KGProjectStorage.getInstance(); await storage.delete(projectName); await fetchProjects(viewMode); if (currentProjectName && projectName === currentProjectName) { onCreateNewProject(); } } catch (error) { console.error('Error permanently deleting project:', error); await showAlert(`Failed to permanently delete project: ${error}`); } }; const handleViewModeChange = (mode: ViewMode) => { setViewMode(mode); setFilter(''); }; const handleOverlayMouseDown = useCallback((e: React.MouseEvent) => { mouseDownOnOverlay.current = e.target === e.currentTarget; }, []); const handleOverlayClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget && mouseDownOnOverlay.current) { startClose(); } }, [startClose]); const SortIcon: React.FC<{ field: SortField }> = ({ field }) => { if (sortField !== field) return null; return sortDirection === 'asc' ? : ; }; return (

Open Project

setFilter(e.target.value)} autoFocus />
{viewMode === 'trash' && (
Deleted projects are automatically removed after 30 days.
)}
handleSortClick('name')} > Name handleSortClick('createdAt')} > Created handleSortClick('updatedAt')} > Updated
{isLoading ? (
Loading projects...
) : filteredProjects.length === 0 ? (
{projects.length === 0 ? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects') : 'No projects match the filter'}
) : (
{filteredProjects.map((project) => (
handleOpen(project.name) : undefined} >
{project.name}
Created {formatDate(project.createdAt)} · Updated {formatDate(project.updatedAt)} {viewMode === 'trash' && project.deletedAt && ( <> · Deleted {formatDate(project.deletedAt)} )}
{viewMode === 'projects' ? ( <> ) : ( <> )}
))}
)}
); }; export default OpenProjectModal;