mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 21:17:13 +09:00
feat(ui): WorkspaceDetail page + demo workspace cards (S8.7)
The demo's missing /workspaces/:id detail view, built on real data only: - new pages/WorkspaceDetail.jsx: back-link + site-header hero (brand tile, active-workspace / status Pill, mono slug-members-since meta), Workspace info card (sk-info-row) + live Resources count strip, accent-underline tabs (Servers / Services / Sites / Members) over sk-dtable rows with Manage links, move-in/out pickers, member add/remove, per-app Share ACL modal, Set active (localStorage scope + reload, mirrors WorkspaceSwitcher), Settings edit modal (updateWorkspace), Archive/ Restore + admin Delete - list page slimmed to the demo card: clickable card -> detail, "Open >" footer, .ws-card.active accent wash for the scoped workspace; the members/resources/sharing modals moved into the detail page - route /workspaces/:id added Deferred (S5 backend): plan/billing, aggregated CPU/MEM usage bars, member last_active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a97f8a4215
commit
2312cf4448
4 files changed
+780
-348
No files matched your search
@@ -44,6 +44,7 @@ import SourceConnectionCallback from './pages/SourceConnectionCallback';
|
||||
import DatabaseMigration from './pages/DatabaseMigration';
|
||||
import ServerTemplates from './pages/ServerTemplates';
|
||||
import Workspaces from './pages/Workspaces';
|
||||
import WorkspaceDetail from './pages/WorkspaceDetail';
|
||||
import DNSZones from './pages/DNSZones';
|
||||
import StatusPages from './pages/StatusPages';
|
||||
import PublicStatusPage from './pages/PublicStatusPage';
|
||||
@@ -270,6 +271,7 @@ function AppRoutes() {
|
||||
<Route path="agent-plugins" element={<Navigate to="/marketplace" replace />} />
|
||||
<Route path="server-templates" element={<ServerTemplates />} />
|
||||
<Route path="workspaces" element={<Workspaces />} />
|
||||
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
|
||||
<Route path="dns" element={<DNSZones />} />
|
||||
<Route path="status-pages" element={<StatusPages />} />
|
||||
<Route path="cloud" element={<CloudProvision />} />
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import Spinner from '../components/Spinner';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { Pill, ServiceTile, SegControl } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
LayoutGrid, ChevronLeft, ChevronRight, Check, Server, Box, Globe,
|
||||
Users, Settings2, Plus, Archive, ArchiveRestore, Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Mirrors WorkspaceSwitcher: the active workspace lives in localStorage and is
|
||||
// sent ambiently as the X-Workspace-Id header; switching reloads so every page
|
||||
// re-fetches under the new scope.
|
||||
const ACTIVE_KEY = 'active_workspace_id';
|
||||
const ACCENT_KEY = 'workspace_accent';
|
||||
|
||||
const formatSince = (iso) => {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? null
|
||||
: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const SERVER_PILL = { online: 'green', pending: 'amber', offline: 'red' };
|
||||
const APP_PILL = { running: 'green', stopped: 'gray', failed: 'red' };
|
||||
|
||||
const WorkspaceDetail = () => {
|
||||
const { id } = useParams();
|
||||
const wsId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [ws, setWs] = useState(null);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('servers');
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [form, setForm] = useState(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [sharingApp, setSharingApp] = useState(null);
|
||||
const [grants, setGrants] = useState([]);
|
||||
const [grantRole, setGrantRole] = useState('editor');
|
||||
|
||||
const asServerList = (data) => (Array.isArray(data) ? data : (data?.servers || []));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
// allWorkspaces neutralizes the ambient X-Workspace-Id header so the
|
||||
// move-in pickers can see resources living in other workspaces.
|
||||
const [wsData, mData, appData, srvData, uData] = await Promise.all([
|
||||
api.getWorkspace(wsId),
|
||||
api.getWorkspaceMembers(wsId).catch(() => ({ members: [] })),
|
||||
api.getApps({ allWorkspaces: true }).catch(() => ({ apps: [] })),
|
||||
api.getServers({ allWorkspaces: true }).catch(() => []),
|
||||
api.getUsers().catch(() => ({ users: [] })),
|
||||
]);
|
||||
setWs(wsData);
|
||||
setMembers(mData.members || []);
|
||||
setApps(appData.apps || []);
|
||||
setServers(asServerList(srvData));
|
||||
setAllUsers(uData.users || []);
|
||||
} catch (err) {
|
||||
toast.error('Failed to load workspace');
|
||||
setWs(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [wsId, toast]);
|
||||
|
||||
useEffect(() => { setLoading(true); load(); }, [load]);
|
||||
|
||||
const isCurrent = localStorage.getItem(ACTIVE_KEY) === String(wsId);
|
||||
|
||||
const setActiveWorkspace = () => {
|
||||
localStorage.setItem(ACTIVE_KEY, String(wsId));
|
||||
if (ws?.primary_color) localStorage.setItem(ACCENT_KEY, ws.primary_color);
|
||||
else localStorage.removeItem(ACCENT_KEY);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await api.archiveWorkspace(wsId);
|
||||
toast.success('Workspace archived');
|
||||
load();
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
await api.restoreWorkspace(wsId);
|
||||
toast.success('Workspace restored');
|
||||
load();
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.deleteWorkspace(wsId);
|
||||
toast.success('Workspace deleted');
|
||||
navigate('/workspaces');
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
setForm({
|
||||
name: ws.name,
|
||||
description: ws.description || '',
|
||||
max_servers: ws.max_servers || 0,
|
||||
max_users: ws.max_users || 0,
|
||||
primary_color: ws.primary_color || '#6d7cff',
|
||||
});
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.updateWorkspace(wsId, form);
|
||||
toast.success('Workspace updated');
|
||||
setShowEdit(false);
|
||||
load();
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId) => {
|
||||
try {
|
||||
await api.addWorkspaceMember(wsId, userId);
|
||||
toast.success('Member added');
|
||||
load();
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId) => {
|
||||
try {
|
||||
await api.removeWorkspaceMember(memberId);
|
||||
toast.success('Member removed');
|
||||
load();
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleMoveApp = async (appId, workspaceId) => {
|
||||
try {
|
||||
await api.setAppWorkspace(appId, workspaceId);
|
||||
toast.success(workspaceId ? 'Application moved in' : 'Application removed');
|
||||
const data = await api.getApps({ allWorkspaces: true });
|
||||
setApps(data.apps || []);
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleMoveServer = async (serverId, workspaceId) => {
|
||||
try {
|
||||
await api.setServerWorkspace(serverId, workspaceId);
|
||||
toast.success(workspaceId ? 'Server moved in' : 'Server removed');
|
||||
setServers(asServerList(await api.getServers({ allWorkspaces: true })));
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
// Per-app sharing (#33 per-site ACL), re-homed from the old list-page modal.
|
||||
const loadSharing = async (appObj) => {
|
||||
try {
|
||||
const gData = await api.getAppGrants(appObj.id);
|
||||
setGrants(gData.grants || []);
|
||||
setSharingApp(appObj);
|
||||
} catch (err) { toast.error('Failed to load sharing'); }
|
||||
};
|
||||
|
||||
const handleGrant = async (userId) => {
|
||||
try {
|
||||
await api.grantAppAccess(sharingApp.id, userId, grantRole);
|
||||
toast.success('Access granted');
|
||||
const gData = await api.getAppGrants(sharingApp.id);
|
||||
setGrants(gData.grants || []);
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (grantId) => {
|
||||
try {
|
||||
await api.revokeAppAccess(sharingApp.id, grantId);
|
||||
toast.success('Access revoked');
|
||||
const gData = await api.getAppGrants(sharingApp.id);
|
||||
setGrants(gData.grants || []);
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
if (!ws) {
|
||||
return (
|
||||
<div className="page-container workspaces-page ws-detail">
|
||||
<Link className="ws-detail__back" to="/workspaces"><ChevronLeft size={14} /> All workspaces</Link>
|
||||
<EmptyState icon={LayoutGrid} title="Workspace not found" description="It may have been deleted, or you may not have access." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const appsIn = apps.filter(a => a.workspace_id === wsId);
|
||||
const appsOut = apps.filter(a => a.workspace_id !== wsId);
|
||||
const srvIn = servers.filter(s => s.workspace_id === wsId);
|
||||
const srvOut = servers.filter(s => s.workspace_id !== wsId);
|
||||
const services = appsIn.filter(a => a.app_type !== 'wordpress');
|
||||
const sites = appsIn.filter(a => a.app_type === 'wordpress');
|
||||
const since = formatSince(ws.created_at);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'servers', label: 'Servers', icon: Server, count: srvIn.length },
|
||||
{ key: 'services', label: 'Services', icon: Box, count: services.length },
|
||||
{ key: 'sites', label: 'Sites', icon: Globe, count: sites.length },
|
||||
{ key: 'members', label: 'Members', icon: Users, count: members.length },
|
||||
];
|
||||
|
||||
const appRows = (list) => (
|
||||
list.length === 0 ? (
|
||||
<EmptyState icon={tab === 'sites' ? Globe : Box} title={`No ${tab} in this workspace yet`} description="Move one in below." />
|
||||
) : (
|
||||
<div className="ws-detail__tablecard">
|
||||
<table className="sk-dtable">
|
||||
<thead>
|
||||
<tr><th>{tab === 'sites' ? 'Site' : 'Service'}</th><th>Status</th><th style={{ width: 220 }} /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map(a => (
|
||||
<tr key={a.id} className="is-clickable" onClick={() => navigate(`/services/${a.id}`)}>
|
||||
<td>
|
||||
<div className="sk-cell-name">
|
||||
<ServiceTile name={a.name} size={30} />
|
||||
<div>
|
||||
<div>{a.name}</div>
|
||||
<div className="sk-cell-sub">{a.app_type}{a.domain ? ` · ${a.domain}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Pill kind={APP_PILL[a.status] || 'amber'}>{a.status || 'unknown'}</Pill></td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<div className="ws-detail__rowactions">
|
||||
<Button size="sm" variant="outline" onClick={() => loadSharing(a)}>Share</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleMoveApp(a.id, null)}>Remove</Button>
|
||||
<span className="ws-detail__manage" onClick={() => navigate(`/services/${a.id}`)}>
|
||||
Manage <ChevronRight size={13} />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const movePicker = (label, items, render, onPick) => items.length > 0 && (
|
||||
<>
|
||||
<div className="ws-pick-label">{label}</div>
|
||||
<div className="ws-pick">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="ws-pick__item" onClick={() => onPick(item)}>
|
||||
{render(item)}
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-container workspaces-page ws-detail">
|
||||
<Link className="ws-detail__back" to="/workspaces"><ChevronLeft size={14} /> All workspaces</Link>
|
||||
|
||||
<header className="ws-detail__header">
|
||||
<ServiceTile name={ws.name} size={54} gradient={ws.primary_color || undefined} className="ws-detail__tile" />
|
||||
<div className="ws-detail__info">
|
||||
<div className="ws-detail__title">
|
||||
<h1>{ws.name}</h1>
|
||||
{isCurrent
|
||||
? <Pill kind="green">active workspace</Pill>
|
||||
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
|
||||
</div>
|
||||
<div className="ws-detail__meta">
|
||||
<span>/{ws.slug}</span>
|
||||
<span className="dotsep">·</span>
|
||||
<span>{members.length} member{members.length !== 1 ? 's' : ''}</span>
|
||||
{since && <><span className="dotsep">·</span><span>since {since}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ws-detail__actions">
|
||||
{!isCurrent && ws.status === 'active' && (
|
||||
<Button size="sm" onClick={setActiveWorkspace}><Check size={15} /> Set active</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={openEdit}><Settings2 size={15} /> Settings</Button>
|
||||
{ws.status === 'active'
|
||||
? <Button size="sm" variant="secondary" onClick={handleArchive}><Archive size={15} /> Archive</Button>
|
||||
: <Button size="sm" variant="secondary" onClick={handleRestore}><ArchiveRestore size={15} /> Restore</Button>}
|
||||
{user?.is_admin && (
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirm(true)}><Trash2 size={15} /></Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{ws.description && <p className="ws-detail__desc">{ws.description}</p>}
|
||||
|
||||
<div className="ws-detail__grid">
|
||||
<section className="ws-detail__card">
|
||||
<h3>Workspace</h3>
|
||||
<div className="sk-info-row"><span className="k">Slug</span><span className="v">/{ws.slug}</span></div>
|
||||
<div className="sk-info-row"><span className="k">Created</span><span className="v">{since || '—'}</span></div>
|
||||
<div className="sk-info-row"><span className="k">Max servers</span><span className="v">{ws.max_servers > 0 ? ws.max_servers : 'Unlimited'}</span></div>
|
||||
<div className="sk-info-row"><span className="k">Max users</span><span className="v">{ws.max_users > 0 ? ws.max_users : 'Unlimited'}</span></div>
|
||||
</section>
|
||||
<section className="ws-detail__card">
|
||||
<h3>Resources</h3>
|
||||
<div className="ws-card__stats ws-detail__stats">
|
||||
<div><div className="v">{srvIn.length}</div><div className="l">Servers</div></div>
|
||||
<div><div className="v">{services.length}</div><div className="l">Services</div></div>
|
||||
<div><div className="v">{sites.length}</div><div className="l">Sites</div></div>
|
||||
<div><div className="v">{members.length}</div><div className="l">Members</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<nav className="ws-detail__tabs" aria-label="Workspace sections">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`ws-detail__tab ${tab === t.key ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
<t.icon size={15} /> {t.label} <span className="ws-detail__tabcount">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ws-detail__pane">
|
||||
{tab === 'servers' && (
|
||||
<>
|
||||
{srvIn.length === 0 ? (
|
||||
<EmptyState icon={Server} title="No servers in this workspace yet" description="Move one in below." />
|
||||
) : (
|
||||
<div className="ws-detail__tablecard">
|
||||
<table className="sk-dtable">
|
||||
<thead><tr><th>Server</th><th>Status</th><th style={{ width: 160 }} /></tr></thead>
|
||||
<tbody>
|
||||
{srvIn.map(s => (
|
||||
<tr key={s.id} className="is-clickable" onClick={() => navigate(`/servers/${s.id}`)}>
|
||||
<td>
|
||||
<div className="sk-cell-name">
|
||||
<ServiceTile name={s.name} size={30} />
|
||||
<div>
|
||||
<div>{s.name}</div>
|
||||
<div className="sk-cell-sub">{s.ip_address || s.hostname || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Pill kind={SERVER_PILL[s.status] || 'gray'}>{s.status || 'unknown'}</Pill></td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<div className="ws-detail__rowactions">
|
||||
<Button size="sm" variant="destructive" onClick={() => handleMoveServer(s.id, null)}>Remove</Button>
|
||||
<span className="ws-detail__manage" onClick={() => navigate(`/servers/${s.id}`)}>
|
||||
Manage <ChevronRight size={13} />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{movePicker(
|
||||
'Move a server into this workspace',
|
||||
srvOut,
|
||||
s => <span className="ws-pick__name">{s.name}</span>,
|
||||
s => handleMoveServer(s.id, wsId),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(tab === 'services' || tab === 'sites') && (
|
||||
<>
|
||||
{appRows(tab === 'services' ? services : sites)}
|
||||
{movePicker(
|
||||
`Move ${tab === 'sites' ? 'a site' : 'an application'} into this workspace`,
|
||||
appsOut.filter(a => (tab === 'sites') === (a.app_type === 'wordpress')),
|
||||
a => <><span className="ws-pick__name">{a.name}</span><span className="sk-tag">{a.app_type}</span></>,
|
||||
a => handleMoveApp(a.id, wsId),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
<>
|
||||
<div className="ws-detail__tablecard">
|
||||
<table className="sk-dtable">
|
||||
<thead><tr><th>Member</th><th>Role</th><th style={{ width: 120 }} /></tr></thead>
|
||||
<tbody>
|
||||
{members.map(m => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div className="sk-cell-name">
|
||||
<ServiceTile name={m.username || m.email || '?'} size={30} className="ws-row__av" />
|
||||
<div>
|
||||
<div>{m.username || m.email}</div>
|
||||
{m.username && m.email && <div className="sk-cell-sub">{m.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{m.role === 'owner'
|
||||
? <Pill kind="green">{m.role}</Pill>
|
||||
: <span className="sk-tag">{m.role}</span>}
|
||||
</td>
|
||||
<td>
|
||||
{m.role !== 'owner' && (
|
||||
<Button size="sm" variant="destructive" onClick={() => handleRemoveMember(m.id)}>Remove</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{movePicker(
|
||||
'Add a member',
|
||||
allUsers.filter(u => !members.find(m => m.user_id === u.id)),
|
||||
u => (
|
||||
<>
|
||||
<ServiceTile name={u.username || u.email || '?'} size={24} className="ws-row__av" />
|
||||
<span className="ws-pick__name">{u.username || u.email}</span>
|
||||
</>
|
||||
),
|
||||
u => handleAddMember(u.id),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEdit && form && (
|
||||
<div className="modal-overlay" onClick={() => setShowEdit(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Workspace Settings</h2>
|
||||
<button className="modal-close" onClick={() => setShowEdit(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<Textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={2} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Max Servers (0 = unlimited)</label>
|
||||
<Input type="number" value={form.max_servers} onChange={e => setForm({ ...form, max_servers: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Max Users (0 = unlimited)</label>
|
||||
<Input type="number" value={form.max_users} onChange={e => setForm({ ...form, max_users: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Brand Color</label>
|
||||
<input
|
||||
type="color"
|
||||
className="workspace-color-input"
|
||||
value={form.primary_color}
|
||||
onChange={e => setForm({ ...form, primary_color: e.target.value })}
|
||||
aria-label="Workspace brand color"
|
||||
/>
|
||||
<span className="form-hint">Recolors the panel for anyone viewing this workspace.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<Button variant="outline" onClick={() => setShowEdit(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={!form.name}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharingApp && (
|
||||
<div className="modal-overlay" onClick={() => setSharingApp(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Sharing: {sharingApp.name}</h2>
|
||||
<button className="modal-close" onClick={() => setSharingApp(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="form-hint">Grant a user access to this application (and its WordPress site, databases, and domains) without transferring ownership.</p>
|
||||
<div className="ws-rows">
|
||||
{grants.length === 0 && <p className="form-hint">Not shared with anyone yet.</p>}
|
||||
{grants.map(g => (
|
||||
<div key={g.id} className="ws-row">
|
||||
<ServiceTile name={g.username || g.email || '?'} size={28} className="ws-row__av" />
|
||||
<div className="ws-row__id">
|
||||
<strong>{g.username || g.email}</strong>
|
||||
<span className="sk-tag">{g.role}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleRevoke(g.id)}>Revoke</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
<h4>Grant Access</h4>
|
||||
<div className="form-group">
|
||||
<label>Role for new grants</label>
|
||||
<SegControl
|
||||
value={grantRole}
|
||||
onChange={setGrantRole}
|
||||
options={[
|
||||
{ value: 'editor', label: 'Editor · view + operate' },
|
||||
{ value: 'viewer', label: 'Viewer · read-only' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="ws-pick">
|
||||
{allUsers.filter(u => u.id !== sharingApp.user_id && !grants.find(g => g.user_id === u.id)).map(u => (
|
||||
<div key={u.id} className="ws-pick__item" onClick={() => handleGrant(u.id)}>
|
||||
<ServiceTile name={u.username || u.email || '?'} size={24} className="ws-row__av" />
|
||||
<span className="ws-pick__name">{u.username || u.email}</span>
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Delete Workspace"
|
||||
message={`Delete "${ws.name}"? All data will be lost.`}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteConfirm(false)}
|
||||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDetail;
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import Spinner from '../components/Spinner';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { LayoutGrid, Plus } from 'lucide-react';
|
||||
import { PageTopbar, Pill, ServiceTile, SegControl } from '@/components/ds';
|
||||
import { LayoutGrid, Plus, ChevronRight } from 'lucide-react';
|
||||
import { PageTopbar, Pill, ServiceTile } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
// Matches WorkspaceSwitcher: the active workspace id lives in localStorage.
|
||||
const ACTIVE_KEY = 'active_workspace_id';
|
||||
|
||||
// "since Jun 2026" card meta from the workspace's real created_at.
|
||||
const formatSince = (iso) => {
|
||||
if (!iso) return null;
|
||||
@@ -22,22 +24,14 @@ const formatSince = (iso) => {
|
||||
|
||||
const Workspaces = () => {
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showMembersModal, setShowMembersModal] = useState(null);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [showResourcesModal, setShowResourcesModal] = useState(null);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [sharingApp, setSharingApp] = useState(null);
|
||||
const [grants, setGrants] = useState([]);
|
||||
const [grantRole, setGrantRole] = useState('editor');
|
||||
const [form, setForm] = useState({ name: '', description: '', max_servers: 0, max_users: 0, primary_color: '#6d7cff' });
|
||||
|
||||
const activeId = localStorage.getItem(ACTIVE_KEY);
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getWorkspaces();
|
||||
@@ -63,144 +57,6 @@ const Workspaces = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await api.deleteWorkspace(id);
|
||||
toast.success('Workspace deleted');
|
||||
setDeleteConfirm(null);
|
||||
loadWorkspaces();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id) => {
|
||||
try {
|
||||
await api.archiveWorkspace(id);
|
||||
toast.success('Workspace archived');
|
||||
loadWorkspaces();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMembers = async (wsId) => {
|
||||
try {
|
||||
const [mData, uData] = await Promise.all([
|
||||
api.getWorkspaceMembers(wsId),
|
||||
api.getUsers().catch(() => ({ users: [] }))
|
||||
]);
|
||||
setMembers(mData.members || []);
|
||||
setAllUsers(uData.users || []);
|
||||
setShowMembersModal(wsId);
|
||||
} catch (err) {
|
||||
toast.error('Failed to load members');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (wsId, userId) => {
|
||||
try {
|
||||
await api.addWorkspaceMember(wsId, userId);
|
||||
toast.success('Member added');
|
||||
loadMembers(wsId);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId) => {
|
||||
try {
|
||||
await api.removeWorkspaceMember(memberId);
|
||||
toast.success('Member removed');
|
||||
if (showMembersModal) loadMembers(showMembersModal);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const asServerList = (data) => (Array.isArray(data) ? data : (data?.servers || []));
|
||||
|
||||
const loadResources = async (wsId) => {
|
||||
try {
|
||||
// allWorkspaces so we can see resources in OTHER workspaces to move them in.
|
||||
const [appData, srvData] = await Promise.all([
|
||||
api.getApps({ allWorkspaces: true }),
|
||||
api.getServers({ allWorkspaces: true }).catch(() => []),
|
||||
]);
|
||||
setApps(appData.apps || []);
|
||||
setServers(asServerList(srvData));
|
||||
setShowResourcesModal(wsId);
|
||||
} catch (err) {
|
||||
toast.error('Failed to load resources');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveApp = async (appId, workspaceId) => {
|
||||
try {
|
||||
await api.setAppWorkspace(appId, workspaceId);
|
||||
toast.success('Application moved');
|
||||
const data = await api.getApps({ allWorkspaces: true });
|
||||
setApps(data.apps || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveServer = async (serverId, workspaceId) => {
|
||||
try {
|
||||
await api.setServerWorkspace(serverId, workspaceId);
|
||||
toast.success('Server moved');
|
||||
setServers(asServerList(await api.getServers({ allWorkspaces: true })));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Per-app sharing (#33 per-site ACL) — inline sub-view of the Resources modal.
|
||||
const loadSharing = async (appObj) => {
|
||||
try {
|
||||
const [gData, uData] = await Promise.all([
|
||||
api.getAppGrants(appObj.id),
|
||||
api.getUsers().catch(() => ({ users: [] })),
|
||||
]);
|
||||
setGrants(gData.grants || []);
|
||||
setAllUsers(uData.users || []);
|
||||
setSharingApp(appObj);
|
||||
} catch (err) {
|
||||
toast.error('Failed to load sharing');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshGrants = async (appId) => {
|
||||
const gData = await api.getAppGrants(appId);
|
||||
setGrants(gData.grants || []);
|
||||
};
|
||||
|
||||
const handleGrant = async (userId) => {
|
||||
try {
|
||||
await api.grantAppAccess(sharingApp.id, userId, grantRole);
|
||||
toast.success('Access granted');
|
||||
await refreshGrants(sharingApp.id);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (grantId) => {
|
||||
try {
|
||||
await api.revokeAppAccess(sharingApp.id, grantId);
|
||||
toast.success('Access revoked');
|
||||
await refreshGrants(sharingApp.id);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const closeResources = () => {
|
||||
setShowResourcesModal(null);
|
||||
setSharingApp(null);
|
||||
};
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
@@ -227,16 +83,26 @@ const Workspaces = () => {
|
||||
<>
|
||||
<div className="ws-listhead">
|
||||
<h2>Your Workspaces</h2>
|
||||
<span className="ws-listhead__hint">isolate servers & apps by team or project</span>
|
||||
<span className="ws-listhead__hint">open a workspace to manage its resources</span>
|
||||
</div>
|
||||
<div className="ws-grid">
|
||||
{workspaces.map(ws => {
|
||||
const since = formatSince(ws.created_at);
|
||||
const isCurrent = activeId === String(ws.id);
|
||||
return (
|
||||
<div key={ws.id} className="ws-card">
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`ws-card is-clickable ${isCurrent ? 'active' : ''}`}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/workspaces/${ws.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') navigate(`/workspaces/${ws.id}`); }}
|
||||
>
|
||||
<div className="ws-card__top">
|
||||
<ServiceTile name={ws.name} size={38} gradient={ws.primary_color || undefined} />
|
||||
<Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>
|
||||
{isCurrent
|
||||
? <Pill kind="green">active</Pill>
|
||||
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
|
||||
</div>
|
||||
<div className="ws-card__name">{ws.name}</div>
|
||||
<div className="ws-card__meta">/{ws.slug}{since ? ` · since ${since}` : ''}</div>
|
||||
@@ -259,15 +125,8 @@ const Workspaces = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ws-card__actions">
|
||||
<Button size="sm" variant="outline" onClick={() => loadMembers(ws.id)}>Members</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => loadResources(ws.id)}>Resources</Button>
|
||||
{ws.status === 'active' && (
|
||||
<Button size="sm" variant="secondary" onClick={() => handleArchive(ws.id)}>Archive</Button>
|
||||
)}
|
||||
{user?.is_admin && (
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirm(ws)}>Delete</Button>
|
||||
)}
|
||||
<div className="ws-card__open">
|
||||
Open <ChevronRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -321,184 +180,6 @@ const Workspaces = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMembersModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowMembersModal(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Workspace Members</h2>
|
||||
<button className="modal-close" onClick={() => setShowMembersModal(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="ws-rows">
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="ws-row">
|
||||
<ServiceTile name={m.username || m.email || '?'} size={28} className="ws-row__av" />
|
||||
<div className="ws-row__id">
|
||||
<strong>{m.username || m.email}</strong>
|
||||
{m.username && m.email && <div className="ws-row__sub">{m.email}</div>}
|
||||
</div>
|
||||
{m.role === 'owner'
|
||||
? <Pill kind="green">{m.role}</Pill>
|
||||
: <span className="sk-tag">{m.role}</span>}
|
||||
{m.role !== 'owner' && (
|
||||
<Button size="sm" variant="destructive" onClick={() => handleRemoveMember(m.id)}>Remove</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
<h4>Add Member</h4>
|
||||
<div className="ws-pick">
|
||||
{allUsers.filter(u => !members.find(m => m.user_id === u.id)).map(u => (
|
||||
<div key={u.id} className="ws-pick__item" onClick={() => handleAddMember(showMembersModal, u.id)}>
|
||||
<ServiceTile name={u.username || u.email || '?'} size={24} className="ws-row__av" />
|
||||
<span className="ws-pick__name">{u.username || u.email}</span>
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResourcesModal && (() => {
|
||||
const appsIn = apps.filter(a => a.workspace_id === showResourcesModal);
|
||||
const appsOut = apps.filter(a => a.workspace_id !== showResourcesModal);
|
||||
const srvIn = servers.filter(s => s.workspace_id === showResourcesModal);
|
||||
const srvOut = servers.filter(s => s.workspace_id !== showResourcesModal);
|
||||
return (
|
||||
<div className="modal-overlay" onClick={closeResources}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
{sharingApp ? (
|
||||
<>
|
||||
<div className="modal-header">
|
||||
<h2>Sharing: {sharingApp.name}</h2>
|
||||
<button className="modal-close" onClick={closeResources}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<Button size="sm" variant="outline" onClick={() => setSharingApp(null)}>← Back</Button>
|
||||
<p className="form-hint">Grant a user access to this application (and its WordPress site, databases, and domains) without transferring ownership.</p>
|
||||
<div className="ws-rows">
|
||||
{grants.length === 0 && <p className="form-hint">Not shared with anyone yet.</p>}
|
||||
{grants.map(g => (
|
||||
<div key={g.id} className="ws-row">
|
||||
<ServiceTile name={g.username || g.email || '?'} size={28} className="ws-row__av" />
|
||||
<div className="ws-row__id">
|
||||
<strong>{g.username || g.email}</strong>
|
||||
<span className="sk-tag">{g.role}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleRevoke(g.id)}>Revoke</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
<h4>Grant Access</h4>
|
||||
<div className="form-group">
|
||||
<label>Role for new grants</label>
|
||||
<SegControl
|
||||
value={grantRole}
|
||||
onChange={setGrantRole}
|
||||
options={[
|
||||
{ value: 'editor', label: 'Editor · view + operate' },
|
||||
{ value: 'viewer', label: 'Viewer · read-only' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="ws-pick">
|
||||
{allUsers.filter(u => u.id !== sharingApp.user_id && !grants.find(g => g.user_id === u.id)).map(u => (
|
||||
<div key={u.id} className="ws-pick__item" onClick={() => handleGrant(u.id)}>
|
||||
<ServiceTile name={u.username || u.email || '?'} size={24} className="ws-row__av" />
|
||||
<span className="ws-pick__name">{u.username || u.email}</span>
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="modal-header">
|
||||
<h2>Workspace Resources</h2>
|
||||
<button className="modal-close" onClick={closeResources}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<h4>Applications</h4>
|
||||
<div className="ws-rows">
|
||||
{appsIn.length === 0 && <p className="form-hint">No applications in this workspace yet.</p>}
|
||||
{appsIn.map(a => (
|
||||
<div key={a.id} className="ws-row">
|
||||
<ServiceTile name={a.name} size={28} />
|
||||
<div className="ws-row__id">
|
||||
<strong>{a.name}</strong>
|
||||
<span className="sk-tag">{a.app_type}</span>
|
||||
</div>
|
||||
<div className="workspace-row-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => loadSharing(a)}>Share</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleMoveApp(a.id, null)}>Remove</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{appsOut.length > 0 && (
|
||||
<>
|
||||
<div className="ws-pick-label">Move an application into this workspace</div>
|
||||
<div className="ws-pick">
|
||||
{appsOut.map(a => (
|
||||
<div key={a.id} className="ws-pick__item" onClick={() => handleMoveApp(a.id, showResourcesModal)}>
|
||||
<span className="ws-pick__name">{a.name}</span>
|
||||
<span className="sk-tag">{a.app_type}</span>
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<hr />
|
||||
<h4>Servers</h4>
|
||||
<div className="ws-rows">
|
||||
{srvIn.length === 0 && <p className="form-hint">No servers in this workspace yet.</p>}
|
||||
{srvIn.map(s => (
|
||||
<div key={s.id} className="ws-row">
|
||||
<ServiceTile name={s.name} size={28} />
|
||||
<div className="ws-row__id">
|
||||
<strong>{s.name}</strong>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleMoveServer(s.id, null)}>Remove</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{srvOut.length > 0 && (
|
||||
<>
|
||||
<div className="ws-pick-label">Move a server into this workspace</div>
|
||||
<div className="ws-pick">
|
||||
{srvOut.map(s => (
|
||||
<div key={s.id} className="ws-pick__item" onClick={() => handleMoveServer(s.id, showResourcesModal)}>
|
||||
<span className="ws-pick__name">{s.name}</span>
|
||||
<Plus size={14} className="ws-pick__plus" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{deleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Delete Workspace"
|
||||
message={`Delete "${deleteConfirm.name}"? All data will be lost.`}
|
||||
onConfirm={() => handleDelete(deleteConfirm.id)}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,14 +107,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
&.is-clickable { cursor: pointer; }
|
||||
|
||||
// the active (scoped) workspace — prototype `.ws-card.active`
|
||||
&.active {
|
||||
border-color: var(--accent-dim);
|
||||
background:
|
||||
linear-gradient(160deg, var(--accent-bg-soft), transparent 70%),
|
||||
var(--surface);
|
||||
}
|
||||
|
||||
// bottom-right "Open ›" affordance (prototype card footer)
|
||||
&__open {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
margin-top: auto; // pin to bottom → equal-height cards
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-faint);
|
||||
transition: color 0.14s;
|
||||
}
|
||||
|
||||
&:hover .ws-card__open { color: var(--accent-bright); }
|
||||
}
|
||||
|
||||
// ── modals: mono eyebrow headings + soft separators ─────
|
||||
@@ -246,6 +262,183 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKSPACE DETAIL (/workspaces/:id — prototype WorkspaceDetail)
|
||||
// ============================================
|
||||
.ws-detail {
|
||||
|
||||
&__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: $space-4 0 $space-3;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
transition: color 0.14s;
|
||||
|
||||
&:hover { color: var(--accent-bright); }
|
||||
}
|
||||
|
||||
// hero header (prototype .site-header)
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(135deg, var(--surface), var(--surface-2));
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
|
||||
.ws-detail__tile { border-radius: 14px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
&__info { flex: 1; min-width: 0; }
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 21px;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 5px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
|
||||
.dotsep { color: var(--text-ghost); }
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: $space-3 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-dim);
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
// info + resources band (prototype dash-grid span-2 cards)
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $space-4;
|
||||
margin: $space-4 0 $space-5;
|
||||
|
||||
@media (max-width: $breakpoint-md) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
&__card {
|
||||
padding: 16px 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $space-2;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
&__stats { margin: $space-3 0 0; }
|
||||
|
||||
// tab strip (prototype wp-tabs: accent underline + mono counts)
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: color 0.14s, border-color 0.14s;
|
||||
|
||||
&:hover { color: var(--text); }
|
||||
|
||||
&.is-active {
|
||||
color: var(--accent-bright);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabcount {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-ghost);
|
||||
}
|
||||
|
||||
&__pane { margin-top: $space-4; }
|
||||
|
||||
&__tablecard {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__rowactions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
&__manage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.14s;
|
||||
|
||||
&:hover { color: var(--accent-bright); }
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
&__header { flex-wrap: wrap; }
|
||||
&__actions { width: 100%; }
|
||||
&__tabs { overflow-x: auto; }
|
||||
}
|
||||
}
|
||||
|
||||
// Brand-color picker in the workspace create modal (#33). Top-level so it also
|
||||
// applies if the modal is ever portaled outside the page block.
|
||||
.workspace-color-input {
|
||||
|
||||
Reference in new issue
Block a user