- Add dark industrial theme with brand color #c20600 and CSS variables - Create Header component with version display and user dropdown menu - Create StatusBar with connection indicator and project/session display - Create ProjectManager page with CRUD, list view, and inspector - Add JWT Bearer token to API requests for authenticated endpoints - Add project API endpoints (GET/POST/PUT/DELETE /api/v1/projects) - Add ProjectService methods: findById, findBySlug, delete - Add unit tests for project API endpoints - Add Playwright E2E tests for projects flow - Update UI design guide with implementation details - Fix: empty JSON body parsing in web-app.ts middleware
277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import type { User, Project } from '../lib/api';
|
|
import { projectApi } from '../lib/api';
|
|
|
|
interface ProjectManagerProps {
|
|
user: User | null;
|
|
}
|
|
|
|
function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSuccess: () => void }) {
|
|
const [name, setName] = useState('');
|
|
const [slug, setSlug] = useState('');
|
|
const [gitUrl, setGitUrl] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name || !slug) {
|
|
setError('Name and slug are required');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
setError('');
|
|
|
|
try {
|
|
await projectApi.create({ name, slug, gitUrl: gitUrl || undefined });
|
|
onSuccess();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create project');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-lg">
|
|
<h2 className="text-xl font-semibold mb-6">Create New Project</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1">Project Name *</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
|
placeholder="My Project"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1">Project Slug *</label>
|
|
<input
|
|
type="text"
|
|
value={slug}
|
|
onChange={(e) => setSlug(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
|
placeholder="my-project"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">Unique identifier for the project directory</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1">Git Repository URL</label>
|
|
<input
|
|
type="text"
|
|
value={gitUrl}
|
|
onChange={(e) => setGitUrl(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
|
placeholder="https://github.com/user/repo.git"
|
|
/>
|
|
</div>
|
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
|
>
|
|
{submitting ? 'Creating...' : 'Create Project'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectInspector({ project, onDelete }: { project: Project; onDelete: () => void }) {
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm('Are you sure you want to delete this project? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
setDeleting(true);
|
|
try {
|
|
await projectApi.delete(project._id);
|
|
onDelete();
|
|
} catch (err) {
|
|
console.error('Failed to delete project', err);
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-lg">
|
|
<h2 className="text-xl font-semibold mb-4">Project Inspector</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="text-sm text-text-muted">Name</div>
|
|
<div className="text-text-primary">{project.name}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-text-muted">Slug</div>
|
|
<div className="font-mono text-text-primary">{project.slug}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-text-muted">Git URL</div>
|
|
<div className="text-text-primary font-mono text-sm">
|
|
{project.gitUrl || <span className="text-text-muted">Not configured</span>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-text-muted">Status</div>
|
|
<div className="text-text-primary capitalize">{project.status}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-text-muted">Created</div>
|
|
<div className="text-text-primary">
|
|
{new Date(project.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div className="pt-4 border-t border-border-subtle">
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
|
>
|
|
{deleting ? 'Deleting...' : 'Delete Project'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<h3 className="text-lg font-semibold mb-4">Chat Sessions</h3>
|
|
<div className="text-text-muted text-sm">
|
|
No chat sessions yet. Open a chat to start working on this project.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProjectManager({ user }: ProjectManagerProps) {
|
|
const navigate = useNavigate();
|
|
const { slug } = useParams();
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showNewForm, setShowNewForm] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadProjects();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (slug && projects.length > 0) {
|
|
const found = projects.find((p) => p.slug === slug);
|
|
setSelectedProject(found || null);
|
|
}
|
|
}, [slug, projects]);
|
|
|
|
const loadProjects = async () => {
|
|
try {
|
|
const data = await projectApi.getAll();
|
|
setProjects(data);
|
|
} catch (err) {
|
|
console.error('Failed to load projects', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectProject = (project: Project) => {
|
|
navigate(`/projects/${project.slug}`);
|
|
};
|
|
|
|
const handleProjectCreated = () => {
|
|
setShowNewForm(false);
|
|
loadProjects();
|
|
};
|
|
|
|
const handleProjectDeleted = () => {
|
|
navigate('/projects');
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
|
<p className="text-text-muted">Please sign in to view projects.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
|
<p className="text-text-muted">Loading projects...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex bg-bg-primary">
|
|
<aside className="w-64 border-r border-border-subtle bg-bg-secondary flex flex-col">
|
|
<div className="p-3 border-b border-border-subtle">
|
|
<button
|
|
onClick={() => setShowNewForm(true)}
|
|
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
|
|
>
|
|
[New Project]
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2 px-2">
|
|
Projects ({projects.length})
|
|
</div>
|
|
{projects.length === 0 ? (
|
|
<p className="text-sm text-text-muted px-2">No projects yet.</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{projects.map((project) => (
|
|
<button
|
|
key={project._id}
|
|
onClick={() => handleSelectProject(project)}
|
|
className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${
|
|
selectedProject?.slug === project.slug
|
|
? 'bg-brand text-white'
|
|
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
}`}
|
|
>
|
|
{project.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{showNewForm ? (
|
|
<NewProjectForm
|
|
onCancel={() => setShowNewForm(false)}
|
|
onSuccess={handleProjectCreated}
|
|
/>
|
|
) : selectedProject ? (
|
|
<ProjectInspector
|
|
project={selectedProject}
|
|
onDelete={handleProjectDeleted}
|
|
/>
|
|
) : (
|
|
<div className="text-center text-text-muted py-12">
|
|
<p className="mb-4">Select a project to view details</p>
|
|
<p className="text-sm">or create a new project to get started</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |