Files
headquarter/apps/web/src/pages/projects.tsx
T
alex 88a973dc68 feat: workspace-first UI refresh - PR-3 projects page + routing cleanup
- Rewrite ProjectsPage with inline repository and workspace display
- Expandable project cards showing repos + workspace chips
- Inline workspace creation from project page (New Workspace button per repo)
- Workspace chips link to workspace detail page
- Sync/delete actions on workspace chips
- Update Project type: add ProjectWithRepos, RepositorySummary, WorkspaceSummary
- Update listProjects API to return ProjectWithRepos[]
- Update dashboard, sessions, config-profiles to use ProjectWithRepos
- Remove old /projects/:projectId route (RepoWorkspace)
- Add chevron icons to Icon component
- Projects page CSS: project-toggle, repo-block, workspace-grid, workspace-chip
- TypeScript + eslint clean

Quality gates: tsc --noEmit clean, eslint clean
2026-06-01 17:18:51 +02:00

474 lines
12 KiB
TypeScript

/** Projects page with inline repositories and workspaces. */
import { useState } from "react";
import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import {
createWorkspace,
deleteWorkspace,
syncWorkspace,
} from "../api/workspaces";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { useAsyncData } from "../hooks/use-async-data";
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>(
listProjects,
[],
);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
null,
);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [expandedProject, setExpandedProject] = useState<string | null>(null);
const [creatingWorkspace, setCreatingWorkspace] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
const safeProjects = projects ?? [];
const openCreate = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
};
const openEdit = (project: ProjectWithRepos) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Project name is required");
return;
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
reload();
} catch {
setFormError("Failed to save project");
}
};
const handleDelete = async (projectId: string) => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
reload();
} catch {
setDeleteConfirmId(null);
}
};
const handleCreateWorkspace = async (
projectId: string,
repoId: string,
data: { name: string; branch: string },
) => {
setWorkspaceLoading(repoId);
try {
await createWorkspace(projectId, repoId, data);
setCreatingWorkspace(null);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to create workspace");
} finally {
setWorkspaceLoading(null);
}
};
const handleSyncWorkspace = async (
projectId: string,
repoId: string,
workspace: WorkspaceSummary,
) => {
setWorkspaceLoading(workspace.id);
try {
await syncWorkspace(projectId, repoId, workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to sync workspace");
} finally {
setWorkspaceLoading(null);
}
};
const handleDeleteWorkspace = async (
projectId: string,
repoId: string,
workspace: WorkspaceSummary,
) => {
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
setWorkspaceLoading(workspace.id);
try {
await deleteWorkspace(projectId, repoId, workspace.id);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete workspace");
} finally {
setWorkspaceLoading(null);
}
};
const isEmpty = status === "ready" && safeProjects.length === 0;
return (
<section className="stack">
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
<Icon name="add" size="sm" />
New Project
</button>
</div>
{status === "loading" && <LoadingState message="Loading projects..." />}
{status === "error" && (
<ErrorState message="Failed to load projects" onRetry={reload} />
)}
{isEmpty && (
<EmptyState message="No projects yet. Create your first project above." />
)}
{status === "ready" && safeProjects.length > 0 && (
<div className="project-list">
{safeProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
expanded={expandedProject === project.id}
onToggle={() =>
setExpandedProject(
expandedProject === project.id ? null : project.id,
)
}
onEdit={() => openEdit(project)}
onDelete={() => setDeleteConfirmId(project.id)}
deleteConfirm={deleteConfirmId === project.id}
onConfirmDelete={() => void handleDelete(project.id)}
onCancelDelete={() => setDeleteConfirmId(null)}
onCreateWorkspace={(repoId) =>
setCreatingWorkspace({ projectId: project.id, repoId })
}
onWorkspaceAction={(repoId, workspace, action) => {
if (action === "sync") {
void handleSyncWorkspace(project.id, repoId, workspace);
} else if (action === "delete") {
void handleDeleteWorkspace(
project.id,
repoId,
workspace,
);
}
}}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === project.id
? creatingWorkspace.repoId
: null
}
onCancelCreate={() => setCreatingWorkspace(null)}
onSubmitCreate={async (repoId, data) =>
await handleCreateWorkspace(project.id, repoId, data)
}
/>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>
{dialogMode === "create" ? "Create Project" : "Edit Project"}
</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button
className="secondary-button"
onClick={closeDialog}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
/* ─── Project Card ─── */
function ProjectCard({
project,
expanded,
onToggle,
onEdit,
onDelete,
deleteConfirm,
onConfirmDelete,
onCancelDelete,
onCreateWorkspace,
onWorkspaceAction,
workspaceLoading,
showCreateForm,
onCancelCreate,
onSubmitCreate,
}: {
project: ProjectWithRepos;
expanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
deleteConfirm: boolean;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onCreateWorkspace: (repoId: string) => void;
onWorkspaceAction: (
repoId: string,
workspace: WorkspaceSummary,
action: "sync" | "delete",
) => void;
workspaceLoading: string | null;
onCancelCreate: () => void;
showCreateForm: string | null;
onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise<void>;
}) {
return (
<article className="card project-card">
<div className="project-info-row">
<button
className="project-toggle"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
<Icon
name={expanded ? "chevron-down" : "chevron-right"}
size="sm"
/>
<h3>{project.name}</h3>
{project.repositories.length > 0 && (
<span className="repo-count">
{project.repositories.length} repo
{project.repositories.length > 1 ? "s" : ""}
</span>
)}
</button>
<div className="project-actions">
<button className="ghost-button" onClick={onEdit} type="button">
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirm ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={onConfirmDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
className="ghost-button"
onClick={onCancelDelete}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</div>
{expanded && (
<div className="project-detail">
{project.repositories.length === 0 ? (
<p className="muted">No repositories yet.</p>
) : (
<div className="repo-list">
{project.repositories.map((repo) => (
<div key={repo.id} className="repo-block">
<div className="repo-header">
<h4>{repo.name}</h4>
<button
className="btn btn-sm btn-primary"
onClick={() => onCreateWorkspace(repo.id)}
type="button"
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm
projectId={project.id}
repoId={repo.id}
onSubmit={(data) =>
onSubmitCreate(repo.id, data)
}
onCancel={onCancelCreate}
/>
)}
{repo.workspaces.length === 0 ? (
<p className="muted">No workspaces.</p>
) : (
<div className="workspace-grid">
{repo.workspaces.map((ws) => (
<div
key={ws.id}
className={`workspace-chip ${ws.status}`}
>
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
<span className="ws-branch">
<Icon name="branch" size="sm" /> {ws.branch}
</span>
{ws.instance_count > 0 && (
<span className="ws-instances">
{ws.instance_count} tool
{ws.instance_count > 1 ? "s" : ""}
</span>
)}
<div className="ws-actions">
<button
type="button"
disabled={
workspaceLoading === ws.id
}
onClick={() =>
onWorkspaceAction(
repo.id,
ws,
"sync",
)
}
>
<Icon name="refresh" size="sm" />
</button>
<button
type="button"
className="danger-text"
disabled={
workspaceLoading === ws.id
}
onClick={() =>
onWorkspaceAction(
repo.id,
ws,
"delete",
)
}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</article>
);
}