234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
|
|
|
||
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { Card } from '../ui/card';
|
||
|
|
import { Button } from '../ui/button';
|
||
|
|
import { Badge } from '../ui/badge';
|
||
|
|
import { SearchInput } from '../ui/input';
|
||
|
|
import { MoreVertical, Plus, LayoutGrid, List, Loader2, AlertCircle } from 'lucide-react';
|
||
|
|
import { useToast } from '../../context/ToastContext';
|
||
|
|
import { CreateProjectModal } from './projects/CreateProjectModal';
|
||
|
|
import { ProjectDetailView } from './projects/ProjectDetailView';
|
||
|
|
import { projectService, Project } from '../../services/projectService';
|
||
|
|
import { logger } from '@/utils/logger';
|
||
|
|
|
||
|
|
export const ProjectsManager: React.FC = () => {
|
||
|
|
const { addToast } = useToast();
|
||
|
|
const [viewState, setViewState] = useState<'list' | 'detail'>('list');
|
||
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [filter, setFilter] = useState('All');
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadProjects();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadProjects = async () => {
|
||
|
|
try {
|
||
|
|
setLoading(true);
|
||
|
|
const response = await projectService.list();
|
||
|
|
setProjects(response.projects || []);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Failed to load projects', {
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
stack: error instanceof Error ? error.stack : undefined,
|
||
|
|
});
|
||
|
|
// Fallback for demo if API fails
|
||
|
|
setProjects([
|
||
|
|
{ id: 'p1', name: 'Neon Genesis', daw: 'Ableton', bpm: 128, key: 'C Min', status: 'In Progress', collaborators: 2, modified: '2h ago', progress: 65 },
|
||
|
|
{ id: 'p2', name: 'Night City Drift', daw: 'FL Studio', bpm: 140, key: 'F# Min', status: 'Mixing', collaborators: 0, modified: '1d ago', progress: 80 },
|
||
|
|
{ id: 'p3', name: 'Mainframe Breach', daw: 'Logic Pro', bpm: 174, key: 'D Maj', status: 'Mastering', collaborators: 1, modified: '3d ago', progress: 95 },
|
||
|
|
]);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Actions ---
|
||
|
|
|
||
|
|
const handleCreate = async (newProjectData: any) => {
|
||
|
|
try {
|
||
|
|
const newProject = await projectService.create(newProjectData);
|
||
|
|
setProjects([newProject, ...projects]);
|
||
|
|
addToast(`Project "${newProject.name}" created`, "success");
|
||
|
|
} catch (e) {
|
||
|
|
// Fallback
|
||
|
|
const mockProject = { id: `p-${Date.now()}`, ...newProjectData };
|
||
|
|
setProjects([mockProject, ...projects]);
|
||
|
|
addToast("Project created (Offline Mode)", "success");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUpdate = async (updatedProject: any) => {
|
||
|
|
try {
|
||
|
|
await projectService.update(updatedProject.id, updatedProject);
|
||
|
|
setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
|
||
|
|
} catch (e) {
|
||
|
|
setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = async (id: string) => {
|
||
|
|
try {
|
||
|
|
await projectService.delete(id);
|
||
|
|
setProjects(prev => prev.filter(p => p.id !== id));
|
||
|
|
setViewState('list');
|
||
|
|
setSelectedProjectId(null);
|
||
|
|
addToast("Project deleted", "info");
|
||
|
|
} catch (e) {
|
||
|
|
addToast("Failed to delete project", "error");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const openProject = (id: string) => {
|
||
|
|
setSelectedProjectId(id);
|
||
|
|
setViewState('detail');
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Filtering ---
|
||
|
|
const filteredProjects = projects.filter(p => {
|
||
|
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||
|
|
const matchesFilter = filter === 'All' || p.daw === filter;
|
||
|
|
return matchesSearch && matchesFilter;
|
||
|
|
});
|
||
|
|
|
||
|
|
const selectedProject = projects.find(p => p.id === selectedProjectId);
|
||
|
|
|
||
|
|
// --- Render Detail View ---
|
||
|
|
if (viewState === 'detail' && selectedProject) {
|
||
|
|
return (
|
||
|
|
<ProjectDetailView
|
||
|
|
project={selectedProject}
|
||
|
|
onBack={() => setViewState('list')}
|
||
|
|
onUpdate={handleUpdate}
|
||
|
|
onDelete={handleDelete}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Render List View ---
|
||
|
|
return (
|
||
|
|
<div className="space-y-6 animate-fadeIn pb-20">
|
||
|
|
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-3xl font-display font-bold text-white mb-2">ACTIVE PROJECTS</h2>
|
||
|
|
<p className="text-gray-400 font-mono text-sm">Track progress across all your workstations.</p>
|
||
|
|
</div>
|
||
|
|
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => setShowCreateModal(true)}>
|
||
|
|
NEW PROJECT
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Filter Bar */}
|
||
|
|
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
|
||
|
|
<div className="w-full md:w-72">
|
||
|
|
<SearchInput placeholder="Search projects..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0">
|
||
|
|
{['All', 'Ableton', 'FL Studio', 'Logic Pro'].map(daw => (
|
||
|
|
<button
|
||
|
|
key={daw}
|
||
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors border ${filter === daw ? 'bg-kodo-cyan text-black border-kodo-cyan' : 'bg-kodo-slate text-gray-400 border-transparent hover:border-gray-500'}`}
|
||
|
|
onClick={() => setFilter(daw)}
|
||
|
|
>
|
||
|
|
{daw}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<div className="ml-auto flex gap-2">
|
||
|
|
<div className="bg-kodo-void p-1 rounded-lg border border-kodo-steel flex">
|
||
|
|
<button className="p-1.5 rounded bg-kodo-slate text-white"><LayoutGrid className="w-4 h-4" /></button>
|
||
|
|
<button className="p-1.5 rounded text-gray-500 hover:text-white"><List className="w-4 h-4" /></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Projects Grid */}
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex justify-center py-20">
|
||
|
|
<Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
|
|
{filteredProjects.map(project => (
|
||
|
|
<Card
|
||
|
|
key={project.id}
|
||
|
|
variant="gaming"
|
||
|
|
className="group cursor-pointer hover:border-kodo-cyan/50 transition-all hover:-translate-y-1"
|
||
|
|
onClick={() => openProject(project.id)}
|
||
|
|
>
|
||
|
|
<div className="flex justify-between items-start mb-4">
|
||
|
|
<Badge label={project.daw} variant={project.daw === 'Ableton' ? 'cyan' : project.daw === 'FL Studio' ? 'gold' : 'magenta'} />
|
||
|
|
<div onClick={(e) => { e.stopPropagation(); addToast("Options menu"); }}>
|
||
|
|
<MoreVertical className="w-4 h-4 text-gray-500 hover:text-white" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<h3 className="text-xl font-bold text-white mb-1 group-hover:text-kodo-cyan transition-colors truncate">{project.name}</h3>
|
||
|
|
<p className="text-xs text-gray-500 mb-4 font-mono">Last edited {project.modified}</p>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-2 mb-4">
|
||
|
|
<div className="bg-white/5 p-2 rounded text-center border border-white/5">
|
||
|
|
<div className="text-[10px] text-gray-400 uppercase font-bold">BPM</div>
|
||
|
|
<div className="font-bold text-white">{project.bpm}</div>
|
||
|
|
</div>
|
||
|
|
<div className="bg-white/5 p-2 rounded text-center border border-white/5">
|
||
|
|
<div className="text-[10px] text-gray-400 uppercase font-bold">Key</div>
|
||
|
|
<div className="font-bold text-white">{project.key}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-4">
|
||
|
|
<div className="flex justify-between text-xs mb-1">
|
||
|
|
<span className="text-gray-400 font-bold">{project.status}</span>
|
||
|
|
<span className="text-kodo-cyan">{project.progress}%</span>
|
||
|
|
</div>
|
||
|
|
<div className="h-1.5 bg-kodo-steel rounded-full overflow-hidden">
|
||
|
|
<div className="h-full bg-kodo-cyan" style={{width: `${project.progress}%`}}></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-between items-center pt-4 border-t border-gray-800">
|
||
|
|
<div className="flex -space-x-2">
|
||
|
|
<div className="w-6 h-6 rounded-full bg-gray-600 border border-black"></div>
|
||
|
|
{project.collaborators > 0 && (
|
||
|
|
<div className="w-6 h-6 rounded-full bg-kodo-ink border border-black flex items-center justify-center text-[10px] text-white">+{project.collaborators}</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<Button variant="ghost" size="sm" className="text-xs h-8">OPEN</Button>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Add New Placeholder */}
|
||
|
|
<div
|
||
|
|
className="border-2 border-dashed border-kodo-steel rounded-xl flex flex-col items-center justify-center p-8 hover:bg-kodo-slate/20 transition-colors cursor-pointer text-gray-500 hover:text-kodo-cyan hover:border-kodo-cyan min-h-[250px]"
|
||
|
|
onClick={() => setShowCreateModal(true)}
|
||
|
|
>
|
||
|
|
<div className="w-16 h-16 bg-kodo-ink rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||
|
|
<Plus className="w-8 h-8 opacity-50" />
|
||
|
|
</div>
|
||
|
|
<span className="font-mono font-bold">START NEW PROJECT</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{filteredProjects.length === 0 && !loading && (
|
||
|
|
<div className="text-center py-20 text-gray-500">
|
||
|
|
<AlertCircle className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||
|
|
<p>No projects found matching your filters.</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{showCreateModal && (
|
||
|
|
<CreateProjectModal
|
||
|
|
onClose={() => setShowCreateModal(false)}
|
||
|
|
onCreate={handleCreate}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|