ab79080f0b
Frontend: - Create reusable DataStates components (LoadingState, ErrorState, EmptyState) - Refactor 12 pages to use shared state components instead of inline JSX - Extract useInstanceActions hook to eliminate session action duplication - Update dashboard and sessions pages to use shared hook OpenSpec: - Archive completed mobile-app-usability change (44/44 tasks) - Archive completed add-config-profiles change (15/15 tasks) Quality: TypeScript check passes, production build succeeds
217 lines
6.9 KiB
TypeScript
217 lines
6.9 KiB
TypeScript
import { useState } from "react";
|
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
import {
|
|
createProject,
|
|
deleteProject,
|
|
listProjects,
|
|
updateProject,
|
|
type ProjectCreateInput,
|
|
type ProjectUpdateInput,
|
|
} from "../api/projects";
|
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
|
import { Icon } from "../components/icon";
|
|
import { useAsyncData } from "../hooks/use-async-data";
|
|
import type { Project } from "../types";
|
|
|
|
type DialogMode = "none" | "create" | "edit";
|
|
|
|
export const ProjectsPage = () => {
|
|
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
|
const [editingProject, setEditingProject] = useState<Project | 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 safeProjects = projects ?? [];
|
|
|
|
const openCreate = () => {
|
|
setFormName("");
|
|
setFormDescription("");
|
|
setFormError(null);
|
|
setEditingProject(null);
|
|
setDialogMode("create");
|
|
};
|
|
|
|
const openEdit = (project: Project) => {
|
|
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 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) => (
|
|
<article className="card project-card" key={project.id}>
|
|
<div className="project-info">
|
|
<h3>{project.name}</h3>
|
|
{project.description && <p className="muted">{project.description}</p>}
|
|
</div>
|
|
<div className="project-actions">
|
|
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
|
Open Workspace
|
|
</Link>
|
|
<button
|
|
className="ghost-button"
|
|
onClick={() => openEdit(project)}
|
|
type="button"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
Edit
|
|
</button>
|
|
{deleteConfirmId === project.id ? (
|
|
<div className="delete-confirm">
|
|
<span>Are you sure?</span>
|
|
<button
|
|
className="danger-button"
|
|
onClick={() => void handleDelete(project.id)}
|
|
type="button"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
Delete
|
|
</button>
|
|
<button
|
|
className="ghost-button"
|
|
onClick={() => setDeleteConfirmId(null)}
|
|
type="button"
|
|
>
|
|
<Icon name="cancel" size="sm" />
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="ghost-button danger-text"
|
|
onClick={() => setDeleteConfirmId(project.id)}
|
|
type="button"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</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>
|
|
);
|
|
};
|