Files
headquarter/apps/web/src/pages/ProjectsPage.tsx
T
Developer 530225d37c feat: mobile Spaces nav and list-based project/workspace views
- Combine Projects and Workspaces into a single 'Spaces' grouped mobile
  nav item that opens a bottom-sheet menu.
- Add SpacesBottomSheet component with Projects/Workspaces options.
- Extend MobileListView with optional renderItem prop for rich rows.
- Redesign mobile ProjectsPage rows to show project description and
  repository chips.
- Replace mobile WorkspacesPage list with compact WorkspaceCard grid,
  matching desktop card content.
- Add mobile-list-* CSS and mobile-workspaces-list spacing.

Quality gates: npm run typecheck, npm run lint clean,
npm test -- --run 87 passed.
2026-06-13 19:32:16 +00:00

319 lines
8.6 KiB
TypeScript

import { useState } from "react";
import {
EmptyState,
ErrorState,
LoadingState,
} from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { ProjectCard } from "../components/features/project/ProjectCard";
import { ProjectDialog } from "../components/features/project/ProjectDialog";
import { RepositoryCreateDialog } from "../components/features/project/repository-create-dialog";
import { MobileListView } from "../components/features/mobile/mobile-list-view";
import { MobileFAB } from "../components/features/mobile/mobile-fab";
import { useProjects } from "../hooks/use-projects";
import type { ProjectWithRepos } from "../types";
type MobileView = "list" | "detail" | "create-project" | "create-repo";
export const ProjectsPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [selectedProject, setSelectedProject] =
useState<ProjectWithRepos | null>(null);
const {
projects,
status,
reload,
dialogMode,
formName,
setFormName,
formDescription,
setFormDescription,
formError,
deleteConfirmId,
setDeleteConfirmId,
expandedProject,
setExpandedProject,
creatingWorkspace,
setCreatingWorkspace,
workspaceLoading,
openCreate,
openEdit,
closeDialog,
handleSubmit,
handleDelete,
handleSyncWorkspace,
handleDeleteWorkspace,
} = useProjects();
const isEmpty = status === "ready" && projects.length === 0;
/* ── Mobile views ── */
if (isMobile) {
if (mobileView === "create-project") {
return (
<div className="mobile-page">
<ProjectDialog
mode="create"
name={formName}
description={formDescription}
error={formError}
onNameChange={setFormName}
onDescriptionChange={setFormDescription}
onSubmit={handleSubmit}
onCancel={() => {
closeDialog();
setMobileView("list");
}}
/>
</div>
);
}
if (mobileView === "create-repo" && selectedProject) {
return (
<div className="mobile-page">
<RepositoryCreateDialog
projectId={selectedProject.id}
open={true}
title="Add Repository"
onClose={() => setMobileView("detail")}
onCreated={async () => {
setMobileView("detail");
await reload();
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedProject) {
return (
<div className="mobile-page">
<ProjectCard
project={selectedProject}
expanded={true}
showBackButton={true}
deleteConfirm={deleteConfirmId === selectedProject.id}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === selectedProject.id
? creatingWorkspace.repoId
: null
}
onBack={() => {
setMobileView("list");
setSelectedProject(null);
}}
onEdit={() => {
setFormName(selectedProject.name);
setFormDescription(selectedProject.description ?? "");
setMobileView("create-project");
}}
onDelete={() => setDeleteConfirmId(selectedProject.id)}
onConfirmDelete={() => {
void handleDelete(selectedProject.id);
setMobileView("list");
setSelectedProject(null);
}}
onCancelDelete={() => setDeleteConfirmId(null)}
onCreateWorkspace={(repoId) =>
setCreatingWorkspace({
projectId: selectedProject.id,
repoId,
})
}
onWorkspaceAction={(repoId, workspace, action) => {
if (action === "sync") {
void handleSyncWorkspace(selectedProject.id, repoId, workspace);
} else if (action === "delete") {
void handleDeleteWorkspace(workspace);
}
}}
onAddRepository={() => setMobileView("create-repo")}
onCancelCreate={() => setCreatingWorkspace(null)}
onCreated={() => {
setCreatingWorkspace(null);
reload();
}}
/>
</div>
);
}
/* Mobile list view */
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Projects</h1>
<span className="muted">
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>
</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" && projects.length > 0 && (
<MobileListView
items={projects.map((p) => ({
id: p.id,
title: p.name,
}))}
renderItem={(item) => {
const project = projects.find((p) => p.id === item.id);
if (!project) return null;
return (
<div className="mobile-list-item-content mobile-list-item-content-rich">
<div className="mobile-list-item-title">{project.name}</div>
{project.description && (
<p className="mobile-list-item-description">
{project.description}
</p>
)}
<div className="mobile-list-item-chips">
{(project.repositories?.length ?? 0) === 0 ? (
<span className="mobile-list-item-chip muted">No repositories</span>
) : (
project.repositories.map((repo) => (
<span key={repo.id} className="mobile-list-item-chip">
{repo.name}
</span>
))
)}
</div>
</div>
);
}}
onItemClick={(id) => {
const project = projects.find((p) => p.id === id);
if (project) {
setSelectedProject(project);
setMobileView("detail");
}
}}
emptyMessage="No projects yet"
/>
)}
<MobileFAB
onClick={() => {
openCreate();
setMobileView("create-project");
}}
label="Create project"
/>
</div>
);
}
/* ── Desktop view ── */
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" && projects.length > 0 && (
<div className="project-list">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
expanded={expandedProject === project.id}
deleteConfirm={deleteConfirmId === project.id}
workspaceLoading={workspaceLoading}
showCreateForm={
creatingWorkspace?.projectId === project.id
? creatingWorkspace.repoId
: null
}
onToggle={() =>
setExpandedProject(
expandedProject === project.id ? null : project.id,
)
}
onEdit={() => openEdit(project)}
onDelete={() => setDeleteConfirmId(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(workspace);
}
}}
onCancelCreate={() => setCreatingWorkspace(null)}
onAddRepository={() => {
setSelectedProject(project);
setMobileView("create-repo");
}}
onCreated={() => {
setCreatingWorkspace(null);
reload();
}}
/>
))}
</div>
)}
{dialogMode !== "none" && (
<ProjectDialog
mode={dialogMode}
name={formName}
description={formDescription}
error={formError}
onNameChange={setFormName}
onDescriptionChange={setFormDescription}
onSubmit={handleSubmit}
onCancel={closeDialog}
/>
)}
{mobileView === "create-repo" && selectedProject && (
<RepositoryCreateDialog
projectId={selectedProject.id}
open={true}
title="Add Repository"
onClose={() => {
setMobileView("list");
setSelectedProject(null);
}}
onCreated={async () => {
setMobileView("list");
setSelectedProject(null);
await reload();
}}
/>
)}
</section>
);
};