gadget/gadget-code/frontend/src/pages/ProjectManager.tsx
Rob Colbert 2e9571a74e Implement dark industrial theme, Project Manager, and JWT authentication
- 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
2026-04-28 17:13:49 -04:00

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>
);
}