feat: add Open Project modal with project browser, soft delete, and duplicate

Replace the prompt-based project loading with a full modal UI:
- Draggable/resizable panel with overlay and backdrop blur
- Filterable project list with incremental fuzzy matching
- Sortable columns (name, created, updated) with asc/desc toggle
- Duplicate project with auto-rename on name conflict
- Soft delete with trash view, restore, and permanent delete
- Auto-purge trash after 30 days
- Orphan media file cleanup on project open
- Skip folders without valid meta.json in project listing
This commit is contained in:
Xiaohan-Tian
2026-04-10 13:28:01 -07:00
parent 21a19918b5
commit 1ff9bfcac4
5 changed files with 934 additions and 60 deletions
+56 -55
View File
@@ -24,6 +24,7 @@ import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import KGDropdown from './common/KGDropdown'; import KGDropdown from './common/KGDropdown';
import FileImportModal from './common/FileImportModal'; import FileImportModal from './common/FileImportModal';
import OpenProjectModal from './common/OpenProjectModal';
import { clearChatHistoryAndUI } from '../util/chatUtil'; import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
@@ -56,6 +57,9 @@ const Toolbar: React.FC = () => {
// State for import modal // State for import modal
const [showImportModal, setShowImportModal] = React.useState(false); const [showImportModal, setShowImportModal] = React.useState(false);
// State for open project modal
const [showOpenProject, setShowOpenProject] = React.useState(false);
// Key signature options // Key signature options
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
@@ -86,6 +90,12 @@ const Toolbar: React.FC = () => {
const { loadProject: storeLoadProject } = useProjectStore.getState(); const { loadProject: storeLoadProject } = useProjectStore.getState();
await storeLoadProject(project, savedName); await storeLoadProject(project, savedName);
// Clean up orphan media files (only on open, not on save)
if (savedName) {
const storage = KGProjectStorage.getInstance();
await storage.cleanupOrphanMedia(savedName, project);
}
// Update status to indicate project loaded // Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`); setStatus(`${sourceDescription} loaded successfully`);
@@ -100,73 +110,55 @@ const Toolbar: React.FC = () => {
} }
}; };
// Core logic for creating a new project (no confirmation dialog)
const createNewProject = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("creating new project");
}
cleanupProjectState();
clearChatHistoryAndUI();
const newProject = new KGProject();
const { loadProject: storeLoadProject } = useProjectStore.getState();
storeLoadProject(newProject);
setStatus(`New project "${newProject.getName()}" created`);
if (DEBUG_MODE.TOOLBAR) {
console.log("new project created successfully");
}
};
// Handler functions for file operations // Handler functions for file operations
const handleNewProject = () => { const handleNewProject = () => {
const confirmed = window.confirm("Are you sure you want to create a new project? Any unsaved changes will be lost."); const confirmed = window.confirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
if (confirmed) { if (confirmed) {
if (DEBUG_MODE.TOOLBAR) { createNewProject();
console.log("user clicked new button");
}
// Clean up UI state first
cleanupProjectState();
// Automatically clear chat history when creating a new project
clearChatHistoryAndUI();
// Create a new project with default parameters
const newProject = new KGProject();
// Load the new project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState();
storeLoadProject(newProject);
// Default track creation is handled centrally in the store's loadProject
// Update status to indicate new project created
setStatus(`New project "${newProject.getName()}" created`);
if (DEBUG_MODE.TOOLBAR) {
console.log("new project created successfully");
}
} }
}; };
const handleLoadProject = async () => { const handleLoadProject = () => {
const confirmed = window.confirm("Are you sure you want to load another project? Any unsaved changes will be lost."); if (DEBUG_MODE.TOOLBAR) {
if (confirmed) { console.log("user clicked load button");
if (DEBUG_MODE.TOOLBAR) { }
console.log("user clicked load button"); setShowOpenProject(true);
} };
// Ask user for project name const handleOpenProjectSelect = async (projectNameToLoad: string) => {
const projectNameToLoad = window.prompt("Enter the project name to load:"); try {
const storage = KGProjectStorage.getInstance();
const loadedProject = await storage.load(projectNameToLoad);
// Check if user input is empty or null (user cancelled) if (!loadedProject) {
if (!projectNameToLoad || projectNameToLoad.trim() === '') { window.alert(`Project "${projectNameToLoad}" not found.`);
if (projectNameToLoad !== null) { // Only show error if user didn't cancel
window.alert("Project name cannot be empty. Please enter a valid project name.");
}
return; return;
} }
try { await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
// Try to load the project from storage } catch (error) {
const storage = KGProjectStorage.getInstance(); console.error("Error loading project:", error);
const loadedProject = await storage.load(projectNameToLoad.trim()); window.alert(`An error occurred while loading the project: ${error}`);
if (!loadedProject) {
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
return;
}
// Use common loading logic — pass the OPFS folder name so savedProjectName is set correctly
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad.trim());
} catch (error) {
console.error("Error loading project:", error);
window.alert(`An error occurred while loading the project: ${error}`);
}
} }
}; };
@@ -842,6 +834,15 @@ const Toolbar: React.FC = () => {
title="Import Project" title="Import Project"
description="Drag and drop your project file here" description="Drag and drop your project file here"
/> />
{showOpenProject && (
<OpenProjectModal
onClose={() => setShowOpenProject(false)}
onOpenProject={handleOpenProjectSelect}
currentProjectName={savedProjectName}
onCreateNewProject={createNewProject}
/>
)}
</> </>
); );
}; };
+285
View File
@@ -0,0 +1,285 @@
/* Overlay — blocks and blurs content below */
.open-project-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
z-index: 2000;
}
/* Open Project Panel — follows PianoRoll panel pattern */
.open-project-panel {
position: fixed;
background-color: #2d2d2d;
border: 1px solid #3a3a3a;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header — draggable */
.open-project-header {
height: 30px;
background-color: #1e1e1e;
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: center;
padding: 0 10px;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.open-project-title {
flex: 1;
text-align: center;
font-size: 14px;
color: #e0e0e0;
text-transform: uppercase;
}
/* Filter bar — sticky at top */
.open-project-filter {
padding: 10px 12px;
background-color: #252525;
border-bottom: 1px solid #3a3a3a;
flex-shrink: 0;
}
.open-project-filter-row {
display: flex;
align-items: center;
gap: 10px;
}
.open-project-filter input {
flex: 1;
padding: 6px 10px;
background-color: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #e0e0e0;
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.open-project-filter input:focus {
border-color: #7b68ee;
}
.open-project-filter input::placeholder {
color: #777;
}
/* View toggle (Projects / Trash) */
.open-project-view-toggle {
display: flex;
border: 1px solid #3a3a3a;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.open-project-toggle-btn {
background-color: #1e1e1e;
color: #999;
border: none;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
}
.open-project-toggle-btn:not(:last-child) {
border-right: 1px solid #3a3a3a;
}
.open-project-toggle-btn:hover {
color: #e0e0e0;
}
.open-project-toggle-btn.active {
background-color: #7b68ee;
color: #fff;
}
/* Trash hint banner */
.open-project-trash-hint {
padding: 6px 12px;
background-color: #3a2a1a;
border-bottom: 1px solid #5a3a1a;
color: #d4a054;
font-size: 11px;
flex-shrink: 0;
}
/* Sort header row */
.open-project-sort-header {
display: flex;
align-items: center;
padding: 6px 12px;
background-color: #252525;
border-bottom: 1px solid #3a3a3a;
font-size: 11px;
color: #999;
text-transform: uppercase;
user-select: none;
flex-shrink: 0;
}
.open-project-sort-col {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: 3px;
}
.open-project-sort-col:hover {
color: #e0e0e0;
background-color: #3a3a3a;
}
.open-project-sort-col.active {
color: #7b68ee;
}
.open-project-sort-col-name {
flex: 1;
}
.open-project-sort-col-created,
.open-project-sort-col-updated {
width: 160px;
text-align: left;
}
/* Scrollable project list */
.open-project-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Individual project row */
.open-project-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #333;
cursor: pointer;
transition: background-color 0.1s;
}
.open-project-item:hover {
background-color: #353535;
}
.open-project-item-info {
flex: 1;
min-width: 0;
}
.open-project-item-name {
font-size: 14px;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.open-project-item-meta {
font-size: 11px;
color: #888;
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Action buttons container */
.open-project-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: 10px;
}
/* Open button */
.open-project-item-open-btn {
background-color: #7b68ee;
color: #fff;
border: none;
border-radius: 4px;
padding: 5px 14px;
font-size: 12px;
cursor: pointer;
}
.open-project-item-open-btn:hover {
background-color: #6a58d6;
}
/* Icon action buttons (duplicate, delete, restore, perm-delete) */
.open-project-item-action-btn {
background: transparent;
border: 1px solid #555;
border-radius: 4px;
color: #bbb;
padding: 5px 8px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s, color 0.1s, border-color 0.1s;
}
.open-project-item-action-btn:hover {
background-color: #444;
color: #e0e0e0;
border-color: #777;
}
.open-project-item-permdelete-btn:hover {
background-color: #5a2020;
color: #ff6666;
border-color: #ff4444;
}
.open-project-item-restore-btn:hover {
background-color: #1a3a2a;
color: #66cc88;
border-color: #44aa66;
}
/* Loading and empty states */
.open-project-loading,
.open-project-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #888;
font-size: 14px;
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;
}
+442
View File
@@ -0,0 +1,442 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { FaTimes, FaGripLines, 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';
interface OpenProjectModalProps {
onClose: () => void;
onOpenProject: (projectName: string) => Promise<void>;
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;
/**
* 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 => {
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<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 [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
// Sort
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// View mode toggle
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) => {
setIsLoading(true);
try {
const storage = KGProjectStorage.getInstance();
// Auto-purge old trash when switching to trash view
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]);
// Escape key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [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) => {
if (field === sortField) {
setSortDirection(d => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection(field === 'name' ? 'asc' : 'desc');
}
}, [sortField]);
// Filter + sort projects
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]);
// --- Action handlers ---
const handleOpen = async (projectName: string) => {
await onOpenProject(projectName);
onClose();
};
const handleDuplicate = async (e: React.MouseEvent, projectName: string) => {
e.stopPropagation();
const newName = window.prompt('Enter a name for the duplicated project:', projectName);
if (!newName || newName.trim() === '') return;
const trimmed = newName.trim();
if (!isValidProjectName(trimmed)) {
window.alert('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);
// Open the duplicated project
await onOpenProject(finalName);
onClose();
} catch (error) {
console.error('Error duplicating project:', error);
window.alert(`Failed to duplicate project: ${error}`);
}
};
const handleSoftDelete = async (e: React.MouseEvent, projectName: string) => {
e.stopPropagation();
try {
const storage = KGProjectStorage.getInstance();
await storage.softDelete(projectName);
// Refresh list
await fetchProjects(viewMode);
} catch (error) {
console.error('Error deleting project:', error);
window.alert(`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);
window.alert(`Failed to restore project: ${error}`);
}
};
const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => {
e.stopPropagation();
const confirmed = window.confirm(
`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 the deleted project is the currently open one, create a new project
if (currentProjectName && projectName === currentProjectName) {
onCreateNewProject();
}
} catch (error) {
console.error('Error permanently deleting project:', error);
window.alert(`Failed to permanently delete project: ${error}`);
}
};
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode);
setFilter('');
};
const SortIcon: React.FC<{ field: SortField }> = ({ field }) => {
if (sortField !== field) return null;
return sortDirection === 'asc' ? <FaSortUp /> : <FaSortDown />;
};
const content = (
<div className="open-project-overlay" onClick={onClose}>
<div
className="open-project-panel"
ref={panelRef}
onClick={(e) => e.stopPropagation()}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
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-row">
<input
type="text"
placeholder="Filter projects..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
autoFocus
/>
<div className="open-project-view-toggle">
<button
className={`open-project-toggle-btn ${viewMode === 'projects' ? 'active' : ''}`}
onClick={() => handleViewModeChange('projects')}
>
Projects
</button>
<button
className={`open-project-toggle-btn ${viewMode === 'trash' ? 'active' : ''}`}
onClick={() => handleViewModeChange('trash')}
>
Trash
</button>
</div>
</div>
</div>
{/* Trash hint */}
{viewMode === 'trash' && (
<div className="open-project-trash-hint">
Deleted projects are automatically removed after 30 days.
</div>
)}
{/* Sort header */}
<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>
{/* Project list */}
{isLoading ? (
<div className="open-project-loading">Loading projects...</div>
) : filteredProjects.length === 0 ? (
<div className="open-project-empty">
{projects.length === 0
? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects')
: 'No projects match the filter'}
</div>
) : (
<div className="open-project-list">
{filteredProjects.map((project) => (
<div
key={project.name}
className="open-project-item"
onClick={viewMode === 'projects' ? () => handleOpen(project.name) : undefined}
>
<div className="open-project-item-info">
<div className="open-project-item-name">{project.name}</div>
<div className="open-project-item-meta">
Created {formatDate(project.createdAt)} &middot; Updated {formatDate(project.updatedAt)}
{viewMode === 'trash' && project.deletedAt && (
<> &middot; Deleted {formatDate(project.deletedAt)}</>
)}
</div>
</div>
<div className="open-project-item-actions">
{viewMode === 'projects' ? (
<>
<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>
);
return createPortal(content, document.body);
};
export default OpenProjectModal;
+1
View File
@@ -2,3 +2,4 @@ export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead'; export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal'; export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay'; export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal';
+146 -1
View File
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader'; import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { isValidProjectName } from '../../util/projectNameUtil'; import { isValidProjectName } from '../../util/projectNameUtil';
import { OPFS_CONSTANTS } from '../../constants/coreConstants'; import { OPFS_CONSTANTS } from '../../constants/coreConstants';
import { KGAudioRegion } from '../region/KGAudioRegion';
export class DuplicateEntryError extends Error { export class DuplicateEntryError extends Error {
constructor(name: string) { constructor(name: string) {
@@ -12,10 +13,11 @@ export class DuplicateEntryError extends Error {
} }
} }
interface ProjectMeta { export interface ProjectMeta {
name: string; name: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
deletedAt?: number | null;
} }
/** /**
@@ -154,6 +156,137 @@ export class KGProjectStorage {
return names.sort(); return names.sort();
} }
/**
* List all projects with metadata (name, createdAt, updatedAt).
* Reads each project's meta.json without loading the full project data.
* @param deleted - if true, return only soft-deleted projects; if false (default), return only non-deleted projects.
*/
public async listWithMeta(deleted: boolean = false): Promise<ProjectMeta[]> {
this.ensureInitialized();
const results: ProjectMeta[] = [];
for await (const entry of this.projectsDirHandle!.values()) {
if (entry.kind === 'directory') {
try {
const dirHandle = await this.projectsDirHandle!.getDirectoryHandle(entry.name);
const metaJson = await this.readFile(dirHandle, OPFS_CONSTANTS.METADATA_FILE);
const meta = JSON.parse(metaJson) as ProjectMeta;
const isDeleted = !!meta.deletedAt;
if (isDeleted === deleted) {
results.push(meta);
}
} catch {
// Skip folders without a valid meta.json (e.g. temporary unsaved projects)
}
}
}
return results.sort((a, b) => b.updatedAt - a.updatedAt);
}
/**
* Soft-delete a project by setting deletedAt in its meta.json.
*/
public async softDelete(name: string): Promise<void> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const meta = await this.readMeta(projectDir, name);
meta.deletedAt = Date.now();
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Restore a soft-deleted project by removing deletedAt from its meta.json.
*/
public async restore(name: string): Promise<void> {
this.ensureInitialized();
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
const meta = await this.readMeta(projectDir, name);
delete meta.deletedAt;
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
}
/**
* Duplicate a project under a new name, copying all files including media.
*/
public async duplicate(sourceName: string, targetName: string): Promise<void> {
this.ensureInitialized();
if (!isValidProjectName(targetName)) {
throw new Error(`Invalid project name "${targetName}".`);
}
// Load the source project
const project = await this.load(sourceName);
if (!project) {
throw new Error(`Project "${sourceName}" not found.`);
}
// Save to new location with fresh timestamps
project.setName(targetName);
await this.save(targetName, project, false);
// Copy media files
await this.copyMediaFiles(sourceName, targetName);
}
/**
* Permanently delete all soft-deleted projects older than the given age in milliseconds.
*/
public async purgeDeletedOlderThan(ageMs: number): Promise<void> {
this.ensureInitialized();
const cutoff = Date.now() - ageMs;
const deleted = await this.listWithMeta(true);
for (const meta of deleted) {
if (meta.deletedAt && meta.deletedAt < cutoff) {
await this.delete(meta.name);
}
}
}
/**
* Remove orphan media files that are not referenced by any track in the project.
*/
public async cleanupOrphanMedia(name: string, project: KGProject): Promise<void> {
this.ensureInitialized();
try {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
let mediaDir: FileSystemDirectoryHandle;
try {
mediaDir = await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
} catch {
return; // No media directory — nothing to clean up
}
// Collect all referenced audio file IDs from the project
const referencedIds = new Set<string>();
for (const track of project.getTracks()) {
for (const region of track.getRegions()) {
if (region instanceof KGAudioRegion) {
const fileId = region.getAudioFileId();
if (fileId) referencedIds.add(fileId);
}
}
}
// Iterate media files and remove orphans
const orphans: string[] = [];
for await (const entry of mediaDir.values()) {
if (entry.kind === 'file' && !referencedIds.has(entry.name)) {
orphans.push(entry.name);
}
}
for (const orphan of orphans) {
await mediaDir.removeEntry(orphan);
console.log(`Removed orphan media file "${orphan}" from project "${name}"`);
}
} catch (error) {
// Non-critical — log but don't throw
console.warn(`Error cleaning up orphan media for project "${name}":`, error);
}
}
/** /**
* Delete a project and all its files. * Delete a project and all its files.
*/ */
@@ -452,4 +585,16 @@ export class KGProjectStorage {
const file = await fileHandle.getFile(); const file = await fileHandle.getFile();
return file.text(); return file.text();
} }
private async readMeta(
projectDir: FileSystemDirectoryHandle,
name: string,
): Promise<ProjectMeta> {
try {
const raw = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
return JSON.parse(raw) as ProjectMeta;
} catch {
return { name, createdAt: 0, updatedAt: 0 };
}
}
} }