feat: add mobile support for Workspaces page with workspace creation
- WorkspacesPage.tsx: detect mobile viewport, show MobileListView / MobileDetailView / MobileFAB - Mobile list: tap workspace to view details (branch, status, path, instances, sync time) - Mobile detail: view workspace fields with Edit and Delete actions - Mobile FAB: opens inline WorkspaceCreateForm - New styles/pages/workspaces.css with responsive grid and card styles - Import workspaces.css in main.tsx Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
This commit is contained in:
@@ -15,6 +15,7 @@ import "./styles/pages/projects.css";
|
|||||||
import "./styles/pages/sessions.css";
|
import "./styles/pages/sessions.css";
|
||||||
import "./styles/pages/ssh-keys.css";
|
import "./styles/pages/ssh-keys.css";
|
||||||
import "./styles/pages/workspace-detail.css";
|
import "./styles/pages/workspace-detail.css";
|
||||||
|
import "./styles/pages/workspaces.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||||
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
||||||
import { WorkspaceCard } from "../components/features/workspace/workspace-card";
|
import { WorkspaceCard } from "../components/features/workspace/workspace-card";
|
||||||
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
|
||||||
|
import { MobileListView } from "../components/features/mobile/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../components/features/mobile/mobile-detail-view";
|
||||||
|
import { MobileFAB } from "../components/features/mobile/mobile-fab";
|
||||||
import { ToolStarter } from "../components/features/tool/tool-starter";
|
import { ToolStarter } from "../components/features/tool/tool-starter";
|
||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
type MobileView = "list" | "detail" | "create";
|
||||||
|
|
||||||
export function WorkspacesPage() {
|
export function WorkspacesPage() {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
|
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||||
|
|
||||||
@@ -28,6 +37,108 @@ export function WorkspacesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Mobile views ── */
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === "create") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<WorkspaceCreateForm
|
||||||
|
onSubmit={async () => {
|
||||||
|
setMobileView("list");
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setMobileView("list")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedWorkspace) {
|
||||||
|
const ws = selectedWorkspace;
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={ws.name}
|
||||||
|
subtitle={`${ws.project_name} / ${ws.repo_name}`}
|
||||||
|
fields={[
|
||||||
|
{ label: "Branch", value: ws.branch },
|
||||||
|
{ label: "Status", value: ws.status },
|
||||||
|
{ label: "Path", value: ws.path },
|
||||||
|
{ label: "Instances", value: ws.instance_count },
|
||||||
|
{ label: "Last Sync", value: ws.last_sync_at ?? "Never" },
|
||||||
|
{ label: "Created", value: ws.created_at },
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
setStartWorkspace(ws);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
void handleDelete(ws);
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedWorkspace(null);
|
||||||
|
}}
|
||||||
|
onBack={() => {
|
||||||
|
setMobileView("list");
|
||||||
|
setSelectedWorkspace(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile list view */
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Workspaces</h1>
|
||||||
|
<span className="muted">{workspaces.length} workspace{workspaces.length !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{loading && workspaces.length === 0 ? (
|
||||||
|
<div className="loading-state">Loading workspaces...</div>
|
||||||
|
) : (
|
||||||
|
<MobileListView
|
||||||
|
items={workspaces.map((ws) => ({
|
||||||
|
id: ws.id,
|
||||||
|
title: ws.name,
|
||||||
|
subtitle: `${ws.project_name} · ${ws.repo_name} · ${ws.branch}`,
|
||||||
|
status: ws.status,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
const ws = workspaces.find((w) => w.id === id);
|
||||||
|
if (ws) {
|
||||||
|
setSelectedWorkspace(ws);
|
||||||
|
setMobileView("detail");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
emptyMessage="No workspaces yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileFAB
|
||||||
|
onClick={() => setMobileView("create")}
|
||||||
|
label="Create workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{startWorkspace && (
|
||||||
|
<div className="modal-overlay" onClick={() => setStartWorkspace(null)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Start Tool</h3>
|
||||||
|
<ToolStarter
|
||||||
|
workspace={startWorkspace}
|
||||||
|
onStarted={() => {
|
||||||
|
setStartWorkspace(null);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setStartWorkspace(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop view ── */
|
||||||
return (
|
return (
|
||||||
<div className="page workspaces-page">
|
<div className="page workspaces-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/** Workspaces page styles */
|
||||||
|
|
||||||
|
/* Desktop */
|
||||||
|
.workspaces-page {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaces-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace card */
|
||||||
|
.workspace-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meta {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meta p {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-syncing {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: var(--danger-light);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-10) var(--space-4);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.workspaces-page {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaces-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user