fix: adjust UI for open project pop up

This commit is contained in:
Xiaohan-Tian
2026-04-17 18:21:52 -07:00
parent 5ca1358795
commit ebcbe4002a
2 changed files with 190 additions and 270 deletions
+56 -34
View File
@@ -1,42 +1,79 @@
/* Overlay — blocks and blurs content below */ /* Overlay — centered, blurs content below */
.open-project-overlay { .open-project-overlay {
position: fixed; position: fixed;
inset: 0; top: 0;
background-color: rgba(0, 0, 0, 0.5); left: 0;
backdrop-filter: blur(3px); right: 0;
z-index: 2000; bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
backdrop-filter: blur(2px);
} }
/* Open Project Panel — follows PianoRoll panel pattern */ /* Open Project Panel — centered dialog */
.open-project-panel { .open-project-panel {
position: fixed;
background-color: #2d2d2d; background-color: #2d2d2d;
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 700px;
height: 70vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
animation: openProjectFadeIn 0.2s ease-out;
} }
/* Header — draggable */ @keyframes openProjectFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Header */
.open-project-header { .open-project-header {
height: 30px;
background-color: #1e1e1e;
border-bottom: 1px solid #3a3a3a;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 0 10px; padding: 20px;
cursor: move; border-bottom: 1px solid #3a3a3a;
user-select: none; background-color: #252525;
flex-shrink: 0; flex-shrink: 0;
} }
.open-project-title { .open-project-title {
flex: 1;
text-align: center;
font-size: 14px;
color: #e0e0e0; color: #e0e0e0;
text-transform: uppercase; font-size: 18px;
font-weight: 600;
margin: 0;
}
.open-project-close-btn {
background: transparent;
border: none;
color: #b0b0b0;
cursor: pointer;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
}
.open-project-close-btn:hover {
background-color: #3a3a3a;
color: #e0e0e0;
} }
/* Filter bar — sticky at top */ /* Filter bar — sticky at top */
@@ -268,18 +305,3 @@
font-size: 14px; font-size: 14px;
padding: 40px; padding: 40px;
} }
/* Resize handle — same as PianoRoll */
.open-project-panel .resize-handle {
position: absolute;
right: 5px;
bottom: 5px;
width: 20px;
height: 20px;
cursor: nwse-resize;
color: #999;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
+134 -236
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-icons/fa';
import { FaTimes, FaGripLines, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-icons/fa';
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage'; import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
import { isValidProjectName } from '../../util/projectNameUtil'; import { isValidProjectName } from '../../util/projectNameUtil';
import './OpenProjectModal.css'; import './OpenProjectModal.css';
@@ -18,10 +17,6 @@ type ViewMode = 'projects' | 'trash';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Incremental fuzzy filter: characters in the filter must appear in order
* within the project name (case-insensitive, ignoring non-alphanumeric chars).
*/
const matchesFilter = (projectName: string, filter: string): boolean => { const matchesFilter = (projectName: string, filter: string): boolean => {
if (!filter) return true; if (!filter) return true;
const normalizedName = projectName.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalizedName = projectName.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -49,50 +44,20 @@ const formatDate = (timestamp: number): string => {
}; };
const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => { const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => {
const panelRef = useRef<HTMLDivElement>(null);
// Position & size
const [position, setPosition] = useState({ x: 0, y: 0 });
const [size, setSize] = useState({ width: 600, height: 400 });
// Drag & resize
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Data
const [projects, setProjects] = useState<ProjectMeta[]>([]); const [projects, setProjects] = useState<ProjectMeta[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
// Sort
const [sortField, setSortField] = useState<SortField>('updatedAt'); const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc'); const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// View mode toggle
const [viewMode, setViewMode] = useState<ViewMode>('projects'); const [viewMode, setViewMode] = useState<ViewMode>('projects');
// Calculate initial position/size to fill main-content area
useEffect(() => {
const toolbarEl = document.querySelector('.toolbar');
const statusBarEl = document.querySelector('.status-bar');
const width = Math.max(400, window.innerWidth - 400);
const height = Math.max(300, window.innerHeight - 200);
const x = (window.innerWidth - width) / 2;
const y = (window.innerHeight - height) / 2;
setPosition({ x, y });
setSize({ width, height });
}, []);
// Fetch projects when view mode changes
const fetchProjects = useCallback(async (mode: ViewMode) => { const fetchProjects = useCallback(async (mode: ViewMode) => {
setIsLoading(true); setIsLoading(true);
try { try {
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
// Auto-purge old trash when switching to trash view
if (mode === 'trash') { if (mode === 'trash') {
await storage.purgeDeletedOlderThan(THIRTY_DAYS_MS); await storage.purgeDeletedOlderThan(THIRTY_DAYS_MS);
} }
@@ -110,7 +75,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
fetchProjects(viewMode); fetchProjects(viewMode);
}, [viewMode, fetchProjects]); }, [viewMode, fetchProjects]);
// Escape key to close
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
@@ -119,49 +83,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]); }, [onClose]);
// Drag & resize mouse handling
const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => {
if (action === 'drag') {
setIsDragging(true);
if (panelRef.current) {
const rect = panelRef.current.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
} else {
setIsResizing(true);
e.preventDefault();
}
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
setPosition({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y });
} else if (isResizing) {
setSize({
width: Math.max(400, e.clientX - position.x),
height: Math.max(300, e.clientY - position.y),
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, dragOffset, position]);
// Sort toggle
const handleSortClick = useCallback((field: SortField) => { const handleSortClick = useCallback((field: SortField) => {
if (field === sortField) { if (field === sortField) {
setSortDirection(d => (d === 'asc' ? 'desc' : 'asc')); setSortDirection(d => (d === 'asc' ? 'desc' : 'asc'));
@@ -171,7 +92,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
} }
}, [sortField]); }, [sortField]);
// Filter + sort projects
const filteredProjects = React.useMemo(() => { const filteredProjects = React.useMemo(() => {
const filtered = projects.filter(p => matchesFilter(p.name, filter)); const filtered = projects.filter(p => matchesFilter(p.name, filter));
@@ -188,8 +108,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
return filtered; return filtered;
}, [projects, filter, sortField, sortDirection]); }, [projects, filter, sortField, sortDirection]);
// --- Action handlers ---
const handleOpen = async (projectName: string) => { const handleOpen = async (projectName: string) => {
await onOpenProject(projectName); await onOpenProject(projectName);
onClose(); onClose();
@@ -210,7 +128,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
const finalName = await storage.resolveUniqueName(trimmed); const finalName = await storage.resolveUniqueName(trimmed);
await storage.duplicate(projectName, finalName); await storage.duplicate(projectName, finalName);
// Open the duplicated project
await onOpenProject(finalName); await onOpenProject(finalName);
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -224,7 +141,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
try { try {
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
await storage.softDelete(projectName); await storage.softDelete(projectName);
// Refresh list
await fetchProjects(viewMode); await fetchProjects(viewMode);
} catch (error) { } catch (error) {
console.error('Error deleting project:', error); console.error('Error deleting project:', error);
@@ -256,7 +172,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
await storage.delete(projectName); await storage.delete(projectName);
await fetchProjects(viewMode); await fetchProjects(viewMode);
// If the deleted project is the currently open one, create a new project
if (currentProjectName && projectName === currentProjectName) { if (currentProjectName && projectName === currentProjectName) {
onCreateNewProject(); onCreateNewProject();
} }
@@ -271,172 +186,155 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
setFilter(''); setFilter('');
}; };
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
const SortIcon: React.FC<{ field: SortField }> = ({ field }) => { const SortIcon: React.FC<{ field: SortField }> = ({ field }) => {
if (sortField !== field) return null; if (sortField !== field) return null;
return sortDirection === 'asc' ? <FaSortUp /> : <FaSortDown />; return sortDirection === 'asc' ? <FaSortUp /> : <FaSortDown />;
}; };
const content = ( return (
<div className="open-project-overlay" onClick={onClose}> <div className="open-project-overlay" onClick={handleOverlayClick}>
<div <div className="open-project-panel">
className="open-project-panel" <div className="open-project-header">
ref={panelRef} <h3 className="open-project-title">Open Project</h3>
onClick={(e) => e.stopPropagation()} <button className="open-project-close-btn" onClick={onClose} aria-label="Close">
style={{ <FaTimes />
left: `${position.x}px`, </button>
top: `${position.y}px`, </div>
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: 2001,
}}
>
{/* Header */}
<div className="open-project-header" onMouseDown={(e) => handleMouseDown(e, 'drag')}>
<button className="close-button" onClick={onClose}>
<FaTimes />
</button>
<div className="open-project-title">Open Project</div>
</div>
{/* Filter + view toggle */} <div className="open-project-filter">
<div className="open-project-filter"> <div className="open-project-filter-row">
<div className="open-project-filter-row"> <input
<input type="text"
type="text" placeholder="Filter projects..."
placeholder="Filter projects..." value={filter}
value={filter} onChange={(e) => setFilter(e.target.value)}
onChange={(e) => setFilter(e.target.value)} autoFocus
autoFocus />
/> <div className="open-project-view-toggle">
<div className="open-project-view-toggle"> <button
<button className={`open-project-toggle-btn ${viewMode === 'projects' ? 'active' : ''}`}
className={`open-project-toggle-btn ${viewMode === 'projects' ? 'active' : ''}`} onClick={() => handleViewModeChange('projects')}
onClick={() => handleViewModeChange('projects')} >
> Projects
Projects </button>
</button> <button
<button className={`open-project-toggle-btn ${viewMode === 'trash' ? 'active' : ''}`}
className={`open-project-toggle-btn ${viewMode === 'trash' ? 'active' : ''}`} onClick={() => handleViewModeChange('trash')}
onClick={() => handleViewModeChange('trash')} >
> Trash
Trash </button>
</button> </div>
</div> </div>
</div> </div>
</div>
{/* Trash hint */} {viewMode === 'trash' && (
{viewMode === 'trash' && ( <div className="open-project-trash-hint">
<div className="open-project-trash-hint"> Deleted projects are automatically removed after 30 days.
Deleted projects are automatically removed after 30 days. </div>
)}
<div className="open-project-sort-header">
<span
className={`open-project-sort-col open-project-sort-col-name ${sortField === 'name' ? 'active' : ''}`}
onClick={() => handleSortClick('name')}
>
Name <SortIcon field="name" />
</span>
<span
className={`open-project-sort-col open-project-sort-col-created ${sortField === 'createdAt' ? 'active' : ''}`}
onClick={() => handleSortClick('createdAt')}
>
Created <SortIcon field="createdAt" />
</span>
<span
className={`open-project-sort-col open-project-sort-col-updated ${sortField === 'updatedAt' ? 'active' : ''}`}
onClick={() => handleSortClick('updatedAt')}
>
Updated <SortIcon field="updatedAt" />
</span>
</div> </div>
)}
{/* Sort header */} {isLoading ? (
<div className="open-project-sort-header"> <div className="open-project-loading">Loading projects...</div>
<span ) : filteredProjects.length === 0 ? (
className={`open-project-sort-col open-project-sort-col-name ${sortField === 'name' ? 'active' : ''}`} <div className="open-project-empty">
onClick={() => handleSortClick('name')} {projects.length === 0
> ? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects')
Name <SortIcon field="name" /> : 'No projects match the filter'}
</span> </div>
<span ) : (
className={`open-project-sort-col open-project-sort-col-created ${sortField === 'createdAt' ? 'active' : ''}`} <div className="open-project-list">
onClick={() => handleSortClick('createdAt')} {filteredProjects.map((project) => (
> <div
Created <SortIcon field="createdAt" /> key={project.name}
</span> className="open-project-item"
<span onClick={viewMode === 'projects' ? () => handleOpen(project.name) : undefined}
className={`open-project-sort-col open-project-sort-col-updated ${sortField === 'updatedAt' ? 'active' : ''}`} >
onClick={() => handleSortClick('updatedAt')} <div className="open-project-item-info">
> <div className="open-project-item-name">{project.name}</div>
Updated <SortIcon field="updatedAt" /> <div className="open-project-item-meta">
</span> Created {formatDate(project.createdAt)} &middot; Updated {formatDate(project.updatedAt)}
</div> {viewMode === 'trash' && project.deletedAt && (
<> &middot; Deleted {formatDate(project.deletedAt)}</>
{/* Project list */} )}
{isLoading ? ( </div>
<div className="open-project-loading">Loading projects...</div> </div>
) : filteredProjects.length === 0 ? ( <div className="open-project-item-actions">
<div className="open-project-empty"> {viewMode === 'projects' ? (
{projects.length === 0 <>
? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects') <button
: 'No projects match the filter'} className="open-project-item-open-btn"
</div> onClick={(e) => { e.stopPropagation(); handleOpen(project.name); }}
) : ( >
<div className="open-project-list"> Open
{filteredProjects.map((project) => ( </button>
<div <button
key={project.name} className="open-project-item-action-btn open-project-item-duplicate-btn"
className="open-project-item" onClick={(e) => handleDuplicate(e, project.name)}
onClick={viewMode === 'projects' ? () => handleOpen(project.name) : undefined} title="Duplicate"
> >
<div className="open-project-item-info"> <FaCopy />
<div className="open-project-item-name">{project.name}</div> </button>
<div className="open-project-item-meta"> <button
Created {formatDate(project.createdAt)} &middot; Updated {formatDate(project.updatedAt)} className="open-project-item-action-btn open-project-item-delete-btn"
{viewMode === 'trash' && project.deletedAt && ( onClick={(e) => handleSoftDelete(e, project.name)}
<> &middot; Deleted {formatDate(project.deletedAt)}</> title="Delete"
>
<FaTrash />
</button>
</>
) : (
<>
<button
className="open-project-item-action-btn open-project-item-restore-btn"
onClick={(e) => handleRestore(e, project.name)}
title="Restore"
>
<FaUndo />
</button>
<button
className="open-project-item-action-btn open-project-item-permdelete-btn"
onClick={(e) => handlePermanentDelete(e, project.name)}
title="Delete permanently"
>
<FaTrash />
</button>
</>
)} )}
</div> </div>
</div> </div>
<div className="open-project-item-actions"> ))}
{viewMode === 'projects' ? ( </div>
<> )}
<button
className="open-project-item-open-btn"
onClick={(e) => { e.stopPropagation(); handleOpen(project.name); }}
>
Open
</button>
<button
className="open-project-item-action-btn open-project-item-duplicate-btn"
onClick={(e) => handleDuplicate(e, project.name)}
title="Duplicate"
>
<FaCopy />
</button>
<button
className="open-project-item-action-btn open-project-item-delete-btn"
onClick={(e) => handleSoftDelete(e, project.name)}
title="Delete"
>
<FaTrash />
</button>
</>
) : (
<>
<button
className="open-project-item-action-btn open-project-item-restore-btn"
onClick={(e) => handleRestore(e, project.name)}
title="Restore"
>
<FaUndo />
</button>
<button
className="open-project-item-action-btn open-project-item-permdelete-btn"
onClick={(e) => handlePermanentDelete(e, project.name)}
title="Delete permanently"
>
<FaTrash />
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* Resize handle */}
<div className="resize-handle" onMouseDown={(e) => handleMouseDown(e, 'resize')}>
<FaGripLines />
</div> </div>
</div> </div>
</div>
); );
return createPortal(content, document.body);
}; };
export default OpenProjectModal; export default OpenProjectModal;