refactor: extract ProjectsPage components
- Extract use-projects hook for state management - Extract ProjectCard and ProjectDialog components - Slim ProjectsPage from 433 to ~100 lines Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
|
||||||
|
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: ProjectWithRepos;
|
||||||
|
expanded: boolean;
|
||||||
|
deleteConfirm: boolean;
|
||||||
|
workspaceLoading: string | null;
|
||||||
|
showCreateForm: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onCreateWorkspace: (repoId: string) => void;
|
||||||
|
onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void;
|
||||||
|
onCancelCreate: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectCard = ({
|
||||||
|
project,
|
||||||
|
expanded,
|
||||||
|
deleteConfirm,
|
||||||
|
workspaceLoading,
|
||||||
|
showCreateForm,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
|
onCreateWorkspace,
|
||||||
|
onWorkspaceAction,
|
||||||
|
onCancelCreate,
|
||||||
|
onCreated,
|
||||||
|
}: Props) => {
|
||||||
|
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 defaultProjectId={project.id} defaultRepoId={repo.id} onSubmit={onCreated} 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
error: string | null;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
onDescriptionChange: (desc: string) => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectDialog = ({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
onNameChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2>{mode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||||
|
<form onSubmit={onSubmit} className="stack">
|
||||||
|
<label className="form-field">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={onCancel} type="button">
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
{mode === "create" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="save" size="sm" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
listProjects,
|
||||||
|
updateProject,
|
||||||
|
type ProjectCreateInput,
|
||||||
|
type ProjectUpdateInput,
|
||||||
|
} from "../api/projects";
|
||||||
|
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
||||||
|
import { useAsyncData } from "./use-async-data";
|
||||||
|
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||||
|
|
||||||
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
|
export const useProjects = () => {
|
||||||
|
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 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 (workspace: WorkspaceSummary) => {
|
||||||
|
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||||
|
setWorkspaceLoading(workspace.id);
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(workspace.id);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: safeProjects,
|
||||||
|
status,
|
||||||
|
reload,
|
||||||
|
dialogMode,
|
||||||
|
formName,
|
||||||
|
setFormName,
|
||||||
|
formDescription,
|
||||||
|
setFormDescription,
|
||||||
|
formError,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
expandedProject,
|
||||||
|
setExpandedProject,
|
||||||
|
creatingWorkspace,
|
||||||
|
setCreatingWorkspace,
|
||||||
|
workspaceLoading,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
closeDialog,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handleSyncWorkspace,
|
||||||
|
handleDeleteWorkspace,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,143 +1,37 @@
|
|||||||
/** Projects page with inline repositories and workspaces. */
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createProject,
|
|
||||||
deleteProject,
|
|
||||||
listProjects,
|
|
||||||
updateProject,
|
|
||||||
type ProjectCreateInput,
|
|
||||||
type ProjectUpdateInput,
|
|
||||||
} from "../api/projects";
|
|
||||||
import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
|
|
||||||
import {
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
} from "../components/data-states";
|
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
import { useProjects } from "../hooks/use-projects";
|
||||||
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const {
|
const {
|
||||||
data: projects,
|
projects,
|
||||||
status,
|
status,
|
||||||
reload,
|
reload,
|
||||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
dialogMode,
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
formName,
|
||||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
setFormName,
|
||||||
null,
|
formDescription,
|
||||||
);
|
setFormDescription,
|
||||||
const [formName, setFormName] = useState("");
|
formError,
|
||||||
const [formDescription, setFormDescription] = useState("");
|
deleteConfirmId,
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
setDeleteConfirmId,
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
expandedProject,
|
||||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
setExpandedProject,
|
||||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
creatingWorkspace,
|
||||||
projectId: string;
|
setCreatingWorkspace,
|
||||||
repoId: string;
|
workspaceLoading,
|
||||||
} | null>(null);
|
openCreate,
|
||||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
openEdit,
|
||||||
|
closeDialog,
|
||||||
|
handleSubmit,
|
||||||
|
handleDelete,
|
||||||
|
handleSyncWorkspace,
|
||||||
|
handleDeleteWorkspace,
|
||||||
|
} = useProjects();
|
||||||
|
|
||||||
const safeProjects = projects ?? [];
|
const isEmpty = status === "ready" && projects.length === 0;
|
||||||
|
|
||||||
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 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 (workspace: WorkspaceSummary) => {
|
|
||||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
|
||||||
setWorkspaceLoading(workspace.id);
|
|
||||||
try {
|
|
||||||
await deleteWorkspace(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 (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -159,13 +53,20 @@ export const ProjectsPage = () => {
|
|||||||
<EmptyState message="No projects yet. Create your first project above." />
|
<EmptyState message="No projects yet. Create your first project above." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && safeProjects.length > 0 && (
|
{status === "ready" && projects.length > 0 && (
|
||||||
<div className="project-list">
|
<div className="project-list">
|
||||||
{safeProjects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
expanded={expandedProject === project.id}
|
expanded={expandedProject === project.id}
|
||||||
|
deleteConfirm={deleteConfirmId === project.id}
|
||||||
|
workspaceLoading={workspaceLoading}
|
||||||
|
showCreateForm={
|
||||||
|
creatingWorkspace?.projectId === project.id
|
||||||
|
? creatingWorkspace.repoId
|
||||||
|
: null
|
||||||
|
}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setExpandedProject(
|
setExpandedProject(
|
||||||
expandedProject === project.id ? null : project.id,
|
expandedProject === project.id ? null : project.id,
|
||||||
@@ -173,7 +74,6 @@ export const ProjectsPage = () => {
|
|||||||
}
|
}
|
||||||
onEdit={() => openEdit(project)}
|
onEdit={() => openEdit(project)}
|
||||||
onDelete={() => setDeleteConfirmId(project.id)}
|
onDelete={() => setDeleteConfirmId(project.id)}
|
||||||
deleteConfirm={deleteConfirmId === project.id}
|
|
||||||
onConfirmDelete={() => void handleDelete(project.id)}
|
onConfirmDelete={() => void handleDelete(project.id)}
|
||||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
onCreateWorkspace={(repoId) =>
|
onCreateWorkspace={(repoId) =>
|
||||||
@@ -186,12 +86,6 @@ export const ProjectsPage = () => {
|
|||||||
void handleDeleteWorkspace(workspace);
|
void handleDeleteWorkspace(workspace);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
workspaceLoading={workspaceLoading}
|
|
||||||
showCreateForm={
|
|
||||||
creatingWorkspace?.projectId === project.id
|
|
||||||
? creatingWorkspace.repoId
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setCreatingWorkspace(null);
|
setCreatingWorkspace(null);
|
||||||
@@ -203,231 +97,17 @@ export const ProjectsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
{dialogMode !== "none" && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<ProjectDialog
|
||||||
<div className="dialog">
|
mode={dialogMode}
|
||||||
<h2>
|
name={formName}
|
||||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
description={formDescription}
|
||||||
</h2>
|
error={formError}
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
onNameChange={setFormName}
|
||||||
<label className="form-field">
|
onDescriptionChange={setFormDescription}
|
||||||
Name
|
onSubmit={handleSubmit}
|
||||||
<input
|
onCancel={closeDialog}
|
||||||
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>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Project Card ─── */
|
|
||||||
|
|
||||||
function ProjectCard({
|
|
||||||
project,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
deleteConfirm,
|
|
||||||
onConfirmDelete,
|
|
||||||
onCancelDelete,
|
|
||||||
onCreateWorkspace,
|
|
||||||
onWorkspaceAction,
|
|
||||||
workspaceLoading,
|
|
||||||
showCreateForm,
|
|
||||||
onCancelCreate,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
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;
|
|
||||||
onCreated: () => 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
|
|
||||||
defaultProjectId={project.id}
|
|
||||||
defaultRepoId={repo.id}
|
|
||||||
onSubmit={onCreated}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user