Pin page actions to the fixed topbar
Each page rendered its primary actions inside the scrolling body, so they drifted away on scroll and diverged from the design, which places them in the topbar next to the breadcrumbs. Introduce a TopbarSlotContext that the Shell publishes from its topbar action area, and a TopbarActions portal that Home, Team, Work, Files, Social and Approvals use to render their controls there.
This commit is contained in:
1 parent
c43f29299f
commit
5f70480907
10 files changed
+95
-92
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
@@ -57,18 +58,14 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Approvals</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
{pending.length} pending · {approved7d} approved this week
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||
<RiskMenu filter={filter} onChange={setFilter} />
|
||||
<Button tone="primary" onClick={approveAllLowRisk}>Approve all low risk</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<RiskMenu filter={filter} onChange={setFilter} />
|
||||
<Button tone="primary" onClick={approveAllLowRisk}>Approve all low risk</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Approvals</h1>
|
||||
<p className="page-sub">
|
||||
{pending.length} pending · {approved7d} approved this week
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginBottom: 24 }}>
|
||||
<StatTile label="Pending" value={String(pending.length)} tone="amber" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import type {
|
||||
AgentRecord,
|
||||
EmployeeRecord,
|
||||
@@ -56,28 +57,24 @@ export function FilesPage({ snapshot, actions }: PageProps) {
|
||||
/>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Files</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
{visibleFiles.length} {visibleFiles.length === 1 ? "file" : "files"} · {formatBytes(totalBytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
|
||||
<Segmented<ViewMode>
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ id: "grid", label: "Grid" },
|
||||
{ id: "list", label: "List" },
|
||||
]}
|
||||
/>
|
||||
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
|
||||
<Icon name="upload" size={13} /> Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
|
||||
<Segmented<ViewMode>
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ id: "grid", label: "Grid" },
|
||||
{ id: "list", label: "List" },
|
||||
]}
|
||||
/>
|
||||
<Button tone="primary" onClick={() => void actions.run(() => window.agentCompany.importFile({ orgId }))}>
|
||||
<Icon name="upload" size={13} /> Upload
|
||||
</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Files</h1>
|
||||
<p className="page-sub">
|
||||
{visibleFiles.length} {visibleFiles.length === 1 ? "file" : "files"} · {formatBytes(totalBytes)}
|
||||
</p>
|
||||
|
||||
{pinnedFiles.length > 0 && section.kind === "all" && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
@@ -39,16 +40,12 @@ export function HomePage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{summaryText}</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={13} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={13} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||
<p className="page-sub">{summaryText}</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginBottom: 12 }}>
|
||||
<TodayCard date={today} tasks={agendaTasks} agents={agents} employee={employee} approvals={pendingApprovals} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import {
|
||||
SOCIAL_PLATFORM,
|
||||
@@ -76,21 +77,17 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Social</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
{accounts.length} {accounts.length === 1 ? "account" : "accounts"} · {scheduled.length} scheduled
|
||||
{operatorName ? ` · managed by ${operatorName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||
<Button onClick={() => setConnectOpen(true)}><Icon name="plus" size={13} /> Connect account</Button>
|
||||
<Button tone="primary" onClick={() => setDraftOpen(true)} disabled={accounts.length === 0}>
|
||||
<Icon name="sparkle" size={13} /> Draft post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<Button onClick={() => setConnectOpen(true)}><Icon name="plus" size={13} /> Connect account</Button>
|
||||
<Button tone="primary" onClick={() => setDraftOpen(true)} disabled={accounts.length === 0}>
|
||||
<Icon name="sparkle" size={13} /> Draft post
|
||||
</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Social</h1>
|
||||
<p className="page-sub">
|
||||
{accounts.length} {accounts.length === 1 ? "account" : "accounts"} · {scheduled.length} scheduled
|
||||
{operatorName ? ` · managed by ${operatorName}` : ""}
|
||||
</p>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
@@ -49,22 +50,18 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Team</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
{employees.length + agents.length} {employees.length + agents.length === 1 ? "member" : "members"} ·
|
||||
{" "}{employees.length} {employees.length === 1 ? "human" : "humans"} ·
|
||||
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||
<Button tone="ghost" onClick={() => cycleFilter(filter, setFilter)} title="Filter">
|
||||
<Icon name="filter" size={13} /> {filterLabel(filter)}
|
||||
</Button>
|
||||
<Button tone="primary" onClick={() => setHireOpen(true)}><Icon name="plus" size={13} /> Hire</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<Button tone="ghost" onClick={() => cycleFilter(filter, setFilter)} title="Filter">
|
||||
<Icon name="filter" size={13} /> {filterLabel(filter)}
|
||||
</Button>
|
||||
<Button tone="primary" onClick={() => setHireOpen(true)}><Icon name="plus" size={13} /> Hire</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Team</h1>
|
||||
<p className="page-sub">
|
||||
{employees.length + agents.length} {employees.length + agents.length === 1 ? "member" : "members"} ·
|
||||
{" "}{employees.length} {employees.length === 1 ? "human" : "humans"} ·
|
||||
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
|
||||
</p>
|
||||
|
||||
<section className="card" style={{ padding: 32, marginBottom: 28 }}>
|
||||
<div className="section-label">Org</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import {
|
||||
TASK_STATUS,
|
||||
type AgentRecord,
|
||||
@@ -54,19 +55,15 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="page-wide">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {agents.length} {agents.length === 1 ? "assignee" : "assignees"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
|
||||
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TopbarActions>
|
||||
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
|
||||
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
<p className="page-sub">
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {agents.length} {agents.length === 1 ? "assignee" : "assignees"}
|
||||
</p>
|
||||
|
||||
{view === "board" && (
|
||||
<BoardView
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Avatar, Icon, IconButton } from "./ui";
|
||||
import type { IconName } from "./Icon";
|
||||
import { OrgSwitcher } from "./OrgSwitcher";
|
||||
import { SearchDialog } from "./SearchDialog";
|
||||
import { TopbarSlotContext } from "./topbar-context";
|
||||
|
||||
export type RouteId =
|
||||
| "home"
|
||||
@@ -35,7 +36,6 @@ export function Shell({
|
||||
snapshot,
|
||||
route,
|
||||
setRoute,
|
||||
actions,
|
||||
onSwitchOrg,
|
||||
onDeleteOrg,
|
||||
onNewOrg,
|
||||
@@ -44,13 +44,13 @@ export function Shell({
|
||||
snapshot: AppSnapshot;
|
||||
route: RouteId;
|
||||
setRoute: (route: RouteId) => void;
|
||||
actions?: ReactNode;
|
||||
onSwitchOrg: (orgId: string) => void;
|
||||
onDeleteOrg: (orgId: string) => void;
|
||||
onNewOrg: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
@@ -123,7 +123,7 @@ export function Shell({
|
||||
<span className="crumb-sep">/</span>
|
||||
<span className="crumb">{activeLabel}</span>
|
||||
<div className="topbar-actions">
|
||||
{actions}
|
||||
<span className="topbar-actions-slot" ref={setActionsSlot} />
|
||||
{window.agentCompany.platform !== "darwin" && (
|
||||
<>
|
||||
<IconButton icon="minus" label="Minimize" onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
@@ -132,7 +132,9 @@ export function Shell({
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="content">{children}</div>
|
||||
<div className="content">
|
||||
<TopbarSlotContext.Provider value={actionsSlot}>{children}</TopbarSlotContext.Provider>
|
||||
</div>
|
||||
</main>
|
||||
<SearchDialog
|
||||
snapshot={snapshot}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TopbarSlotContext } from "./topbar-context";
|
||||
|
||||
export function TopbarActions({ children }: { children: ReactNode }) {
|
||||
const slot = useContext(TopbarSlotContext);
|
||||
if (!slot) return null;
|
||||
return createPortal(children, slot);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* The Shell owns the fixed topbar and publishes its action area as a DOM node
|
||||
* through this context. Pages portal their primary actions into that node via
|
||||
* {@link TopbarActions} so the controls stay pinned to the topbar (matching the
|
||||
* design) instead of scrolling away inside the page body.
|
||||
*/
|
||||
export const TopbarSlotContext = createContext<HTMLElement | null>(null);
|
||||
@@ -291,6 +291,7 @@ a { color: inherit; }
|
||||
.crumb-sub { font-size: 13px; color: var(--text-3); }
|
||||
.crumb-sep { color: var(--text-4); }
|
||||
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||||
.topbar-actions-slot { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
|
||||
Reference in new issue
Block a user