Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
-8
@@ -7,6 +7,9 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
|
||||
import { LayoutProvider } from '@/lib/layout';
|
||||
import { PiggyContextProvider } from '@/lib/piggy-context';
|
||||
import { Shell } from '@/components/Shell';
|
||||
import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
@@ -29,6 +32,8 @@ const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) =>
|
||||
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
||||
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
||||
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
|
||||
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
|
||||
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -103,19 +108,18 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
// that a half-filled registration form is not lost to an accidental Back.
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ id: string; name: string }>('/api/me'),
|
||||
});
|
||||
const { data, isLoading, error, refetch } = useIdentityQuery();
|
||||
|
||||
// Adopt the server's stored appearance preferences once we know who this is.
|
||||
// Adopt the server's stored appearance once we know who this is. Appearance
|
||||
// only: sidebar-collapsed and dock-open are per-device and live in
|
||||
// localStorage, deliberately (see lib/layout.tsx).
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
|
||||
.then((profile) => {
|
||||
const adopt = (window as unknown as { __pigAdoptTheme?: (p: unknown) => void })
|
||||
.__pigAdoptTheme;
|
||||
if (profile && adopt) adopt(profile);
|
||||
if (!profile) return;
|
||||
const host = window as unknown as { __pigAdoptTheme?: (p: unknown) => void };
|
||||
host.__pigAdoptTheme?.(profile);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [data]);
|
||||
@@ -134,6 +138,22 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.needsSignIn) {
|
||||
/*
|
||||
* Learn is the one route reachable without an account. It gates itself on
|
||||
* a share code, and the API only ever serves it platform-track rows — so
|
||||
* sending a code-holder to the sign-in screen would make the code
|
||||
* unusable, which is the whole point of having one.
|
||||
*
|
||||
* Rendered outside Shell deliberately: the page uses no identity, layout
|
||||
* or dock hook, and there is no member to build a workspace chrome for.
|
||||
*/
|
||||
if (window.location.pathname === '/learn') {
|
||||
return (
|
||||
<RoutePage>
|
||||
<Learn />
|
||||
</RoutePage>
|
||||
);
|
||||
}
|
||||
return showRegister ? (
|
||||
<Register
|
||||
config={config}
|
||||
@@ -166,12 +186,26 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdentityProvider identity={data}>
|
||||
<LayoutProvider>
|
||||
<PiggyContextProvider>
|
||||
<AppRoutes />
|
||||
</PiggyContextProvider>
|
||||
</LayoutProvider>
|
||||
</IdentityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Shell />}>
|
||||
<Route index element={<RoutePage><Overview /></RoutePage>} />
|
||||
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
||||
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
|
||||
<Route path="calendar" element={<RoutePage><Calendar /></RoutePage>} />
|
||||
<Route path="learn" element={<RoutePage><Learn /></RoutePage>} />
|
||||
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
||||
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
||||
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The account tile at the top of the sidebar.
|
||||
*
|
||||
* It carries the Piggy mark in the user's own accent, because that accent is
|
||||
* the one piece of the interface they chose and the workspace identity is
|
||||
* where they will look for it. The swatch row in the menu is the same
|
||||
* `setAccent` the Settings page calls — not a copy of the palette, and not a
|
||||
* second place a colour could be defined.
|
||||
*
|
||||
* PIG is single-workspace today, so this is a switcher with one entry. It is
|
||||
* still a menu rather than a label: it is where identity, appearance and
|
||||
* sign-out belong, and the shape does not have to change when a second
|
||||
* workspace appears.
|
||||
*/
|
||||
import { ChevronsUpDown, Check, LogOut, Monitor, Moon, Settings2, Sun } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ThemeMode } from '@pig/core';
|
||||
import { getSupabase } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { PiggyMark } from './PiggyMark';
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { cn } from './ui';
|
||||
|
||||
const WORKSPACE_NAME = 'Prime Intellect Growth';
|
||||
|
||||
const MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
];
|
||||
|
||||
export function AccountSwitcher() {
|
||||
const identity = useIdentity();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const { accent, accents, setAccent, mode, setMode, resolved } = useTheme();
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await getSupabase()?.auth.signOut();
|
||||
} finally {
|
||||
// Belt and braces, matching Settings: if the provider call fails a
|
||||
// reload still lands on sign-in rather than a half-signed-out interface.
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent"
|
||||
aria-label={`${WORKSPACE_NAME} — account and appearance`}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||
<PiggyMark className="size-5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold text-fg">{WORKSPACE_NAME}</span>
|
||||
<span className="truncate text-xs font-normal text-muted">{identity.name}</span>
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-auto size-4 shrink-0 text-muted group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-64"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuLabel className="flex min-w-0 items-center gap-2 py-2">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||
<PiggyMark className="size-5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-semibold">{identity.name}</span>
|
||||
<span className="truncate text-xs font-normal text-muted">{identity.email}</span>
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
|
||||
Accent
|
||||
</DropdownMenuLabel>
|
||||
<div className="flex flex-wrap gap-1.5 px-2 pb-2">
|
||||
{accents.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => setAccent(option.key)}
|
||||
aria-label={option.label}
|
||||
aria-pressed={accent === option.key}
|
||||
title={option.label}
|
||||
className={cn(
|
||||
'grid size-7 place-items-center rounded-full border transition-transform hover:scale-110',
|
||||
accent === option.key ? 'border-fg' : 'border-border',
|
||||
)}
|
||||
// The dark tuning of each accent is a different colour, not a
|
||||
// dimmed one. A swatch showing the light value in dark mode
|
||||
// is a swatch showing a colour the user will not get.
|
||||
style={{
|
||||
background: `hsl(${resolved === 'dark' ? option.dark.accent : option.light.accent})`,
|
||||
}}
|
||||
>
|
||||
{accent === option.key ? (
|
||||
<Check className="size-3.5 text-white mix-blend-difference" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
{MODES.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="min-h-11"
|
||||
onSelect={() => setMode(option.value)}
|
||||
>
|
||||
<option.icon aria-hidden />
|
||||
{option.label}
|
||||
{mode === option.value ? <Check className="ml-auto size-4" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild className="min-h-11">
|
||||
<Link to="/settings" onClick={() => setOpenMobile(false)}>
|
||||
<Settings2 aria-hidden />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="min-h-11" onSelect={() => void signOut()}>
|
||||
<LogOut aria-hidden />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The application header: logo, where you are, search, Piggy.
|
||||
*
|
||||
* Search reads as a field because that is what people look for, but it is a
|
||||
* BUTTON, not an input. It was briefly a real `<input>` that opened the
|
||||
* palette on focus, and that is a keyboard trap: Tab moved into the field,
|
||||
* the modal took over, Escape left focus on `<body>`, and Tab from there ran
|
||||
* the same three elements and reopened the dialog — so no keyboard user could
|
||||
* ever reach the nav, the Piggy toggle or the page. A field that cannot be
|
||||
* focused without being replaced is not a field.
|
||||
*
|
||||
* The alternative — a real input filtering inline and escalating on Enter —
|
||||
* was rejected because the palette is the thing that answers, and an inline
|
||||
* filter would be a second search that ranks differently from the one ⌘K
|
||||
* opens. One search, one ranking; the control that opens it says so honestly.
|
||||
* (This is also what shadcn's own examples do.)
|
||||
*
|
||||
* Full width above both side panes rather than inset between them, so the
|
||||
* logo has somewhere to live and the panes have a fixed edge to hang from.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { activeNavItem, visibleNav } from '@/lib/nav';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { PiggyLogo } from './PiggyMark';
|
||||
import { PiggyDockToggle } from './PiggyDock';
|
||||
import { Button, cn } from './ui';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from './ui/breadcrumb';
|
||||
import { SidebarTrigger } from './ui/sidebar';
|
||||
|
||||
export function AppHeader() {
|
||||
const identity = useIdentity();
|
||||
const { pathname } = useLocation();
|
||||
const items = visibleNav(identity);
|
||||
const current = activeNavItem(items, pathname);
|
||||
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return;
|
||||
event.preventDefault();
|
||||
setCommandOpen((open) => !open);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-40 flex w-full shrink-0 items-center gap-2 border-b border-border',
|
||||
// Translucent with a blur reads as native on iOS; the opaque fallback
|
||||
// keeps text legible where backdrop-filter is unsupported.
|
||||
'bg-surface/90 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75',
|
||||
'pr-[max(0.75rem,var(--safe-right))] lg:pr-[max(1rem,var(--safe-right))]',
|
||||
'pl-[max(0.5rem,var(--safe-left))] lg:pl-[max(0.75rem,var(--safe-left))]',
|
||||
)}
|
||||
style={{ height: 'var(--app-header-h)', paddingTop: 'var(--safe-top)' }}
|
||||
>
|
||||
<SidebarTrigger />
|
||||
|
||||
<Link to="/" className="tap flex shrink-0 items-center rounded-lg px-1" aria-label="PIG home">
|
||||
<PiggyLogo />
|
||||
</Link>
|
||||
|
||||
{current ? (
|
||||
<Breadcrumb className="ml-2 hidden min-w-0 lg:block">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="text-muted">{current.group}</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{current.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
) : null}
|
||||
|
||||
{/* min-w-0 on the search wrapper: without it the 393px header refuses to
|
||||
shrink below the field's intrinsic width and the page scrolls. */}
|
||||
<div className="ml-auto flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={commandOpen}
|
||||
className={cn(
|
||||
'hidden h-9 min-w-0 items-center gap-2 rounded-md border border-input bg-surface-2 px-2.5',
|
||||
'text-left text-sm text-muted shadow-sm transition-colors hover:text-fg md:flex md:w-56 lg:w-72',
|
||||
)}
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">Search pages and workflows…</span>
|
||||
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted md:hidden"
|
||||
aria-label="Search and navigate"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-5" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<PiggyDockToggle />
|
||||
</div>
|
||||
|
||||
<CommandPalette destinations={items} open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The left navigation pane.
|
||||
*
|
||||
* One component for both treatments the sidebar primitive provides: the
|
||||
* collapsible desktop rail and the phone Sheet. Deliberately not two, because
|
||||
* the previous shell had the desktop list and the tab bar as separate JSX and
|
||||
* they had already drifted — the tab bar's active pill and the sidebar's
|
||||
* active row used different tokens.
|
||||
*/
|
||||
import { X } from 'lucide-react';
|
||||
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { Button } from './ui';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from './ui/sidebar';
|
||||
|
||||
export function AppSidebar() {
|
||||
const identity = useIdentity();
|
||||
const items = visibleNav(identity);
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
{/* The Sheet's own close button is suppressed because it lands on top
|
||||
of the account switcher. This one replaces it — Escape and the
|
||||
overlay work, but a visible close is not optional on a touch
|
||||
device where neither is discoverable. */}
|
||||
{isMobile ? (
|
||||
<div className="flex items-center justify-between pl-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
Navigate
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<AccountSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const groupItems = items.filter((item) => item.group === group);
|
||||
// A heading over nothing is worse than a missing section: it reads
|
||||
// as a section that failed to load rather than one you cannot use.
|
||||
if (!groupItems.length) return null;
|
||||
return (
|
||||
<SidebarGroup key={group}>
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{groupItems.map((item) => (
|
||||
<NavItemRow key={item.to} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</SidebarContent>
|
||||
{/*
|
||||
No footer. The old shell ended with "Prime Intellect Growth / Compute
|
||||
revenue system", which the account switcher at the top now says
|
||||
better — and at 900px the fourteen nav rows do not all fit, so a
|
||||
restatement of the workspace name was costing two of them.
|
||||
*/}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItemRow({ item }: { item: NavItem }) {
|
||||
const { setOpenMobile, isMobile } = useSidebar();
|
||||
// `asChild` renders the row *as* the link rather than wrapping one, so there
|
||||
// is a single focusable element per row. Active state is asked of the router
|
||||
// instead of compared against a pathname, so `/demand/abc` still lights
|
||||
// Demand and `/` does not light everything.
|
||||
const resolved = useResolvedPath(item.to);
|
||||
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActive} tooltip={item.label}>
|
||||
<Link
|
||||
to={item.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
<item.icon aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
@@ -30,11 +30,52 @@ export function CommandPalette({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
|
||||
|
||||
// Clear on close rather than on open: reopening must not present yesterday's
|
||||
// query over a list it is already silently filtering. Keyed off `open` and
|
||||
// not the close handler because ⌘K and a selected item both close the dialog
|
||||
// by setting the prop directly.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
// Where focus came from, so it can go back there. This used to be
|
||||
// `onCloseAutoFocus: preventDefault` — necessary while the header's search
|
||||
// control was an input that opened the palette on focus, because restoring
|
||||
// focus reopened the dialog. That control is a button now, and suppressing
|
||||
// restoration left focus on `<body>`: Tab then restarted at the top of the
|
||||
// document, which is a keyboard trap of its own. Radix's own restoration
|
||||
// does not survive this dialog either (measured: focus lands on `<body>`),
|
||||
// so the opener is captured and refocused explicitly.
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (open) opener.current = document.activeElement as HTMLElement | null;
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search pages and workflows…" aria-label="Search pages and workflows" />
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
contentProps={{
|
||||
onCloseAutoFocus: (event) => {
|
||||
const target = opener.current;
|
||||
// `isConnected` because selecting an item navigates, and the opener
|
||||
// may be a control the new route has already unmounted; falling back
|
||||
// to Radix's default is better than focusing a detached node.
|
||||
if (!target || !target.isConnected) return;
|
||||
event.preventDefault();
|
||||
target.focus();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Search pages and workflows…"
|
||||
aria-label="Search pages and workflows"
|
||||
/>
|
||||
<CommandList className="max-h-[min(70dvh,32rem)] p-1">
|
||||
<CommandEmpty>No pages found.</CommandEmpty>
|
||||
{groups.map((group, index) => (
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { get } from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import {
|
||||
streamPiggyChat,
|
||||
type PiggyChatContext,
|
||||
@@ -69,6 +71,10 @@ export function PiggyAskButton({
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = usePiggyStatus();
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
// An explicit prop always wins. Every existing call site passes the record
|
||||
// the user pressed the button on, and the ambient page context is a guess
|
||||
// that must never displace it.
|
||||
const ambient = usePiggyCurrentContext();
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -84,7 +90,7 @@ export function PiggyAskButton({
|
||||
<ResponsivePiggyChat
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
context={context}
|
||||
context={context ?? ambient}
|
||||
initialPrompt={prompt}
|
||||
/>
|
||||
</>
|
||||
@@ -110,7 +116,7 @@ export function PiggyChatWorkspace() {
|
||||
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
|
||||
}
|
||||
|
||||
function ResponsivePiggyChat({
|
||||
export function ResponsivePiggyChat({
|
||||
open,
|
||||
onOpenChange,
|
||||
context,
|
||||
@@ -121,14 +127,17 @@ function ResponsivePiggyChat({
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
}) {
|
||||
const desktop = useDesktop();
|
||||
// The same breakpoint the shell switches navigation at. It used to be `md`,
|
||||
// which meant a 900px tablet got the desktop side sheet sliding in behind
|
||||
// the phone tab bar it was still showing.
|
||||
const desktop = !useIsMobile();
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-border px-5 py-4">
|
||||
<SheetHeader className="border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]">
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</SheetContent>
|
||||
@@ -140,7 +149,7 @@ function ResponsivePiggyChat({
|
||||
<DrawerContent className="h-[92dvh]">
|
||||
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</DrawerContent>
|
||||
@@ -148,14 +157,25 @@ function ResponsivePiggyChat({
|
||||
);
|
||||
}
|
||||
|
||||
function PiggyChatPanel({
|
||||
/**
|
||||
* The transcript and composer. Width-agnostic on purpose — it is used at a
|
||||
* full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
|
||||
*
|
||||
* `compact` is for the dock only. At 22rem the ordinary spacing does not fail,
|
||||
* it just crowds: the assistant avatar takes a tenth of the line, a user
|
||||
* bubble at 88% leaves no gutter to read the alignment from, and the
|
||||
* suggestion buttons wrap to three lines each.
|
||||
*/
|
||||
export function PiggyChatPanel({
|
||||
context,
|
||||
initialPrompt = '',
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
@@ -211,31 +231,31 @@ function PiggyChatPanel({
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div>
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context
|
||||
{(context && context.type !== 'page'
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
|
||||
).map((suggestion) => (
|
||||
<button key={suggestion} type="button" className="min-h-11 rounded-lg border border-border px-3 text-left text-sm hover:bg-surface-2" onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} compact={compact} />)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="border-t border-border bg-surface p-3 sm:p-4" onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {context.label ?? context.type.replaceAll('_', ' ')}</Badge> : null}
|
||||
<form className={cn('border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
@@ -256,19 +276,27 @@ function PiggyChatPanel({
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] text-muted">Read-only session · Check source records before acting on material terms.</p>
|
||||
<p className="mt-2 text-center text-[11px] leading-4 text-muted">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||
/** A page context has no id and its `type` is the literal 'page', which reads
|
||||
* as nothing useful in a badge — show the route the dock is following. */
|
||||
function contextLabel(context: PiggyChatContext): string {
|
||||
if (context.label) return context.label;
|
||||
if (context.type === 'page') return context.route === '/' ? 'Overview' : context.route.slice(1);
|
||||
return context.type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function ChatMessage({ message, compact = false }: { message: TranscriptMessage; compact?: boolean }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
return <div className={cn('ml-auto rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'max-w-[94%] px-3' : 'max-w-[88%] px-4')}><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg"><Bot aria-hidden /></div>
|
||||
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
|
||||
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.reasoning ? (
|
||||
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
|
||||
@@ -312,21 +340,16 @@ function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): Transcri
|
||||
return message;
|
||||
}
|
||||
|
||||
function usePiggyStatus() {
|
||||
/**
|
||||
* The availability gate. Exported because anything that renders a chat surface
|
||||
* — the workspace page, the ask button, the dock — has to check it first:
|
||||
* `/api/piggy/chat` answers 503 when the runtime is off, and a panel that
|
||||
* renders without asking shows a composer that cannot send.
|
||||
*/
|
||||
export function usePiggyStatus() {
|
||||
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
|
||||
}
|
||||
|
||||
function useDesktop(): boolean {
|
||||
const [desktop, setDesktop] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(min-width: 768px)');
|
||||
const update = () => setDesktop(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
return desktop;
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Piggy, docked.
|
||||
*
|
||||
* The third pane. It is a column rather than a sheet because the point of
|
||||
* docking an agent is that you can read the page and the answer at the same
|
||||
* time — a sheet that covers the thing you are asking about defeats it.
|
||||
*
|
||||
* Three surfaces, one panel:
|
||||
*
|
||||
* ≥ xl — this permanent column, remembered between sessions.
|
||||
* ≥ lg — the existing right-hand Sheet, because 1024px minus a sidebar
|
||||
* minus a 22rem dock leaves the page narrower than a phone.
|
||||
* < lg — the existing bottom Drawer.
|
||||
*
|
||||
* The status gate is not optional. `/api/piggy/chat` answers 503 when the
|
||||
* runtime is disabled, so a dock that renders its composer without asking
|
||||
* first is a permanent third of the window that fails on first use.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { PanelRightClose, Sparkles } from 'lucide-react';
|
||||
import { useHasDockRoom } from '@/hooks/use-media-query';
|
||||
import { useLayout } from '@/lib/layout';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
|
||||
import { PiggyMark } from './PiggyMark';
|
||||
import { Button, EmptyState, Skeleton, cn } from './ui';
|
||||
|
||||
export function PiggyDock() {
|
||||
const { dockOpen, setDockOpen } = useLayout();
|
||||
const hasRoom = useHasDockRoom();
|
||||
const status = usePiggyStatus();
|
||||
const context = usePiggyCurrentContext();
|
||||
|
||||
if (!hasRoom || !dockOpen) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
// `xl:flex` as well as the hook: the media query and the class agree, so
|
||||
// there is no frame where the column exists at the wrong width.
|
||||
className={cn(
|
||||
'hidden w-[--dock-width] shrink-0 flex-col overflow-hidden border-l border-border bg-surface xl:flex',
|
||||
'pr-[var(--safe-right)]',
|
||||
)}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 'var(--app-header-h)',
|
||||
height: 'calc(100dvh - var(--app-header-h))',
|
||||
}}
|
||||
aria-label="Piggy"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<PiggyMark className="size-5 shrink-0 text-accent-fg" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold">Piggy</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-9 min-h-0 min-w-0 text-muted"
|
||||
aria-label="Close the Piggy panel"
|
||||
onClick={() => setDockOpen(false)}
|
||||
>
|
||||
<PanelRightClose className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status.isLoading ? (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<Skeleton className="h-20 rounded-xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
</div>
|
||||
) : !status.data?.canUse ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable the isolated Piggy runtime.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
// Remounted per RECORD, so the transcript never carries an answer
|
||||
// about one row into a conversation about another. Page contexts all
|
||||
// share one key: they change on every navigation, and remounting there
|
||||
// threw away the transcript, the composer draft and any in-flight
|
||||
// stream (PiggyChat aborts on unmount) — which is the whole point of a
|
||||
// pane that stays put while you move around the app. The panel reads
|
||||
// `context` at send time, so the page it is asking about still tracks
|
||||
// the route without a remount.
|
||||
<PiggyChatPanel
|
||||
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
|
||||
context={context}
|
||||
compact
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header control for Piggy.
|
||||
*
|
||||
* Below `xl` there is no column to toggle, so the same button opens the sheet
|
||||
* or drawer instead — one affordance in one place, whatever the viewport can
|
||||
* accommodate.
|
||||
*/
|
||||
export function PiggyDockToggle({ className }: { className?: string }) {
|
||||
const { dockOpen, setDockOpen } = useLayout();
|
||||
const hasRoom = useHasDockRoom();
|
||||
const status = usePiggyStatus();
|
||||
const context = usePiggyCurrentContext();
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('text-muted', dockOpen && hasRoom && 'bg-accent-subtle text-accent-fg', className)}
|
||||
disabled={Boolean(unavailable)}
|
||||
aria-pressed={hasRoom ? dockOpen : undefined}
|
||||
aria-label={
|
||||
unavailable
|
||||
? 'Piggy is unavailable'
|
||||
: hasRoom
|
||||
? dockOpen
|
||||
? 'Close the Piggy panel'
|
||||
: 'Open the Piggy panel'
|
||||
: 'Ask Piggy'
|
||||
}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : 'Piggy'}
|
||||
onClick={() => (hasRoom ? setDockOpen(!dockOpen) : setOverlayOpen(true))}
|
||||
>
|
||||
<PiggyMark className="size-5" />
|
||||
</Button>
|
||||
{hasRoom ? null : (
|
||||
<ResponsivePiggyChat open={overlayOpen} onOpenChange={setOverlayOpen} context={context} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+119
-200
@@ -1,216 +1,135 @@
|
||||
/**
|
||||
* The application shell.
|
||||
*
|
||||
* Two navigation treatments rather than one responsive compromise:
|
||||
* Three panes on a desktop, and on a phone the same thing the phone always
|
||||
* had:
|
||||
*
|
||||
* Phone — a bottom tab bar, because the top of a large phone is out of
|
||||
* thumb reach, and iOS users expect primary navigation there.
|
||||
* Desktop — a persistent sidebar, because the horizontal room exists and
|
||||
* hiding navigation behind a hamburger on a 27-inch display wastes
|
||||
* it.
|
||||
* Header — full width above everything, carrying the logo, where you are,
|
||||
* the search field and Piggy. Full width rather than inset between
|
||||
* the panes so both panes have one fixed edge to hang beneath, and
|
||||
* so the sticky offset is a single CSS variable rather than a
|
||||
* number repeated in three components.
|
||||
* Left — navigation, collapsing to a 60px icon rail. Sticky in the flex
|
||||
* row rather than `fixed` with a matching padding on the content:
|
||||
* a padding that has to be kept in step with a width is exactly
|
||||
* the pair that drifts, and the flex row makes the compiler's job
|
||||
* the browser's job.
|
||||
* Right — Piggy, docked from `xl` up. Below that it is the sheet or the
|
||||
* drawer it has always been.
|
||||
* Phone — the bottom tab bar, unchanged, because the top of a large phone
|
||||
* is out of thumb reach. The full navigation is additionally
|
||||
* reachable through the sidebar's Sheet, from the header trigger.
|
||||
*
|
||||
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
|
||||
* — it has the width, and the bottom bar looks lost across a tablet.
|
||||
* `lg` is still the breakpoint at which the tab bar gives way to the sidebar —
|
||||
* an iPad in portrait has the width, and a bottom bar looks lost across a
|
||||
* tablet. It is now declared once, in hooks/use-media-query.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Boxes,
|
||||
Building2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Search,
|
||||
MessageCircleMore,
|
||||
ShieldCheck,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Target,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
||||
import { CommandPalette, type CommandDestination } from './CommandPalette';
|
||||
import { Button, cn } from './ui';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { useLayout } from '@/lib/layout';
|
||||
import { visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { AppHeader } from './AppHeader';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { PiggyDock } from './PiggyDock';
|
||||
import { SidebarInset, SidebarProvider } from './ui/sidebar';
|
||||
import { cn } from './ui';
|
||||
|
||||
interface NavItem extends CommandDestination {
|
||||
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||
primary?: boolean;
|
||||
group: 'Intelligence' | 'Marketplace' | 'Records' | 'Control';
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true },
|
||||
{ to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' },
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
|
||||
{ to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records' },
|
||||
{ to: '/team', label: 'Team', icon: Users, group: 'Control' },
|
||||
{ to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, group: 'Control' },
|
||||
];
|
||||
|
||||
const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
|
||||
/** How much room Piggy takes when docked. Read by the dock and by nothing else. */
|
||||
const DOCK_WIDTH = '22rem';
|
||||
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const current = NAV.find((item) =>
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return;
|
||||
event.preventDefault();
|
||||
setCommandOpen((open) => !open);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
const identity = useIdentity();
|
||||
const { sidebarOpen, setSidebarOpen, dockOpen } = useLayout();
|
||||
const items = visibleNav(identity);
|
||||
|
||||
return (
|
||||
<div className="app-canvas min-h-dvh bg-bg">
|
||||
{/* ------------------------------------------------- desktop sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface/95 backdrop-blur-xl lg:flex',
|
||||
// Respect the safe area on notched displays in landscape.
|
||||
'pl-[var(--safe-left)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 items-center border-b border-border/70 px-5">
|
||||
<PiggyLogo />
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="Workspace">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group} className="mb-3 last:mb-0">
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80">
|
||||
{group}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{NAV.filter((item) => item.group === group).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group flex min-h-[44px] items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-[background-color,color,transform]',
|
||||
isActive
|
||||
? 'bg-accent-subtle text-accent-fg shadow-sm'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg active:translate-x-0.5',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mx-3 mb-3 min-h-[44px] justify-start text-muted"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
<SidebarProvider
|
||||
open={sidebarOpen}
|
||||
onOpenChange={setSidebarOpen}
|
||||
className="app-canvas flex-col bg-bg"
|
||||
style={
|
||||
{
|
||||
// Both side panes stick beneath the header and subtract it from the
|
||||
// viewport. One variable, so collapsing or resizing anything is a
|
||||
// CSS relayout and never a measurement in JavaScript.
|
||||
'--sidebar-offset-top': 'var(--app-header-h)',
|
||||
'--dock-width': DOCK_WIDTH,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex w-full min-w-0 flex-1">
|
||||
<AppSidebar />
|
||||
|
||||
<SidebarInset
|
||||
// Clears the tab bar and the home indicator beneath it. Without this
|
||||
// the last row of any list is unreachable on a phone. Four pages set
|
||||
// their own `md:pb-0` on top of this; keeping `lg` here means they
|
||||
// still have their padding between md and lg, where the tab bar is
|
||||
// very much still on screen.
|
||||
className="pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0"
|
||||
>
|
||||
<Search className="h-4 w-4" aria-hidden />
|
||||
Search
|
||||
<kbd className="ml-auto rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<p className="text-xs font-medium text-fg">Prime Intellect Growth</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted">Compute revenue system</p>
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto w-full min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-8',
|
||||
// With Piggy docked the middle pane is already a column in a
|
||||
// three-column layout; capping it at 7xl and centring it again
|
||||
// strands the content between two gutters it does not need.
|
||||
dockOpen ? 'max-w-[86rem]' : 'max-w-7xl',
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
{/* ---------------------------------------------------- mobile header */}
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
|
||||
// A translucent bar with a blur reads as native on iOS; the opaque
|
||||
// fallback keeps text legible where backdrop-filter is unsupported.
|
||||
'bg-surface/90 px-4 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75 lg:hidden',
|
||||
'pt-[var(--safe-top)]',
|
||||
)}
|
||||
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
|
||||
>
|
||||
<PiggyMark className="h-6 w-6 text-accent-fg" />
|
||||
<span className="font-semibold lowercase tracking-tight">
|
||||
{current?.label ?? 'pig'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
aria-label="Search and navigate"
|
||||
>
|
||||
<Search className="h-5 w-5" aria-hidden />
|
||||
</Button>
|
||||
</header>
|
||||
<PiggyDock />
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------- content */}
|
||||
<main
|
||||
className={cn(
|
||||
'lg:pl-60',
|
||||
// Bottom padding clears the tab bar and the home indicator beneath
|
||||
// it. Without this the last row of any list is unreachable.
|
||||
'pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0',
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ------------------------------------------------- mobile tab bar */}
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
|
||||
'supports-[backdrop-filter]:bg-surface/80',
|
||||
)}
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="mx-auto flex max-w-lg items-stretch justify-around">
|
||||
{NAV.filter((item) => item.primary).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className="tap flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
|
||||
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<span className={isActive ? 'text-accent-fg' : undefined}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<CommandPalette destinations={NAV} open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</div>
|
||||
<MobileTabBar items={items.filter((item) => item.primary)} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone tab bar. Unchanged in look and behaviour — it is the thing this
|
||||
* product is best at and the rebuild had no business touching it.
|
||||
*/
|
||||
function MobileTabBar({ items }: { items: NavItem[] }) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
|
||||
'supports-[backdrop-filter]:bg-surface/80',
|
||||
)}
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="mx-auto flex max-w-lg items-stretch justify-around">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
|
||||
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<span className={cn('truncate', isActive && 'text-accent-fg')}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & { separator?: React.ReactNode }
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & { asChild?: boolean }
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-medium text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -1,57 +1,32 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
/**
|
||||
* shadcn's import path for the button.
|
||||
*
|
||||
* There is only one Button in PIG now — see the note in `./index`. This module
|
||||
* exists so the shadcn compositions written against `@/components/ui/button`
|
||||
* keep working unchanged, and it supplies the one thing they genuinely need
|
||||
* that the PIG default does not: a bare `<Button>` here means a solid brand
|
||||
* fill (shadcn's `default`), whereas a bare `<Button>` from `./index` means the
|
||||
* quiet secondary. Changing either default silently restyles the other's call
|
||||
* sites, which is why the shim is a default rather than a second component.
|
||||
*
|
||||
* `[&_svg]:size-4` likewise preserves shadcn's icon sizing for these call
|
||||
* sites without imposing it on every PIG button in the app.
|
||||
*/
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button as BaseButton, buttonVariants, cn, type ButtonProps } from './index';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'default', size = 'default', className, ...props }, ref) => (
|
||||
<BaseButton
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('[&_svg]:size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
|
||||
@@ -21,10 +21,19 @@ const Command = React.forwardRef<
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
const CommandDialog = ({
|
||||
children,
|
||||
contentProps,
|
||||
...props
|
||||
}: DialogProps & {
|
||||
contentProps?: React.ComponentPropsWithoutRef<typeof DialogContent>
|
||||
}) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogContent
|
||||
{...contentProps}
|
||||
className={cn("overflow-hidden p-0", contentProps?.className)}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
forwardRef,
|
||||
type ButtonHTMLAttributes,
|
||||
@@ -24,9 +25,25 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
|
||||
// ------------------------------------------------------------------- button
|
||||
|
||||
/**
|
||||
* One button, two vocabularies.
|
||||
*
|
||||
* There used to be two Button *components* — this one and a verbatim shadcn
|
||||
* copy at `@/components/ui/button` with a different variant vocabulary
|
||||
* (`default`/`destructive`/`link`) and a 36px size scale that fails PIG's own
|
||||
* 44px touch-target rule. Two implementations of the same control drift, and
|
||||
* these two already had: one grew a `danger` variant, the other a `link`.
|
||||
*
|
||||
* They are now a single cva. Both vocabularies are declared here as aliases of
|
||||
* the same classes, so `variant="primary"` and `variant="default"` are the
|
||||
* same button, and `@/components/ui/button` is a re-export that only supplies
|
||||
* shadcn's different *default* variant. The remaining work is to retire the
|
||||
* shadcn names at the three call sites that use them and delete the shim.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
|
||||
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'[&_svg]:shrink-0 ' +
|
||||
// touch-manipulation removes the 300ms tap delay that older mobile Safari
|
||||
// applies while waiting to see whether a tap is a double-tap zoom.
|
||||
'touch-manipulation select-none whitespace-nowrap',
|
||||
@@ -38,6 +55,10 @@ const buttonVariants = cva(
|
||||
outline: 'border border-border bg-transparent hover:bg-surface-2',
|
||||
ghost: 'bg-transparent hover:bg-surface-2',
|
||||
danger: 'bg-danger text-white hover:opacity-90',
|
||||
/* shadcn's vocabulary, mapped onto the same three treatments. */
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:opacity-90 active:opacity-80',
|
||||
destructive: 'bg-danger text-white hover:opacity-90',
|
||||
link: 'bg-transparent text-accent-fg underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
// min-h keeps the target tappable even when the label is short.
|
||||
@@ -45,20 +66,34 @@ const buttonVariants = cva(
|
||||
md: 'h-11 min-h-[44px] px-4',
|
||||
lg: 'h-12 min-h-[48px] px-6 text-base',
|
||||
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
|
||||
/* shadcn's `default` size. Deliberately PIG's height, not 36px. */
|
||||
default: 'h-11 min-h-[44px] px-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'secondary', size: 'md' },
|
||||
},
|
||||
);
|
||||
|
||||
export { buttonVariants };
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
VariantProps<typeof buttonVariants> {
|
||||
/** Render the child element instead of a `<button>`, keeping the classes. */
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
),
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The compact, desktop-density input the shadcn compositions are written
|
||||
* against — deliberately NOT the same component as `Input` from
|
||||
* `@/components/ui`, which is 44px because it is used on phone forms.
|
||||
*
|
||||
* Use this one only where the control is desktop-only (the header search
|
||||
* field, a sidebar filter). Anything that can be touched wants the 44px one.
|
||||
* The base stylesheet still forces a 16px font size here, so Safari does not
|
||||
* zoom the viewport if one ever does end up on a phone.
|
||||
*/
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 shadow-sm transition-colors',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* The sidebar primitive.
|
||||
*
|
||||
* shadcn's `sidebar` block, with its API kept intact and two deliberate
|
||||
* changes to its internals:
|
||||
*
|
||||
* 1. The desktop pane is `sticky`, not `fixed`. Upstream renders an
|
||||
* invisible width-holding div next to a `fixed inset-y-0` pane so the
|
||||
* pane can slide fully off-canvas. PIG only ever wants the icon rail, and
|
||||
* a sticky pane in a flex row gets the same collapse animation from one
|
||||
* element instead of two — and, unlike `inset-y-0`, it can start below a
|
||||
* full-width application header. That header is the whole point of the
|
||||
* layout, so the fixed variant was not usable as shipped.
|
||||
* 2. Every control clears 44px, and the icon rail is 64px rather than
|
||||
* shadcn's 48px so that a 44px button still has gutters. A 32px icon
|
||||
* button is the one thing in the upstream block that fails PIG's own
|
||||
* touch-target rule, and the rail is reachable on a tablet.
|
||||
*
|
||||
* Colours come from `--sidebar-*` in index.css, which alias the existing
|
||||
* surface and accent variables rather than introducing a second palette — so
|
||||
* the sidebar re-tints with the user's chosen accent and needs no dark-mode
|
||||
* pass of its own.
|
||||
*
|
||||
* `collapsible="offcanvas"`, the `floating` and `inset` variants and the
|
||||
* submenu parts are not implemented, because nothing here uses them and an
|
||||
* unexercised variant is a variant that is quietly broken.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { Button } from './index';
|
||||
import { Separator } from './separator';
|
||||
import { Skeleton } from './skeleton';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'pig_sidebar_state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
export const SIDEBAR_WIDTH = '16rem';
|
||||
export const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
/**
|
||||
* 64px, not shadcn's 48px. A menu button collapses to a 44px square — PIG's
|
||||
* touch minimum — and the group padding around it is 8px a side, so 48px
|
||||
* would clip it against the border.
|
||||
*/
|
||||
export const SIDEBAR_WIDTH_ICON = '4rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
||||
interface SidebarContextValue {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextValue | null>(null);
|
||||
|
||||
export function useSidebar(): SidebarContextValue {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
return context;
|
||||
}
|
||||
|
||||
export const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? internalOpen;
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean) => {
|
||||
if (setOpenProp) setOpenProp(value);
|
||||
else setInternalOpen(value);
|
||||
// A cookie as well as whatever the caller persists: it is the only
|
||||
// store the document can read before React has mounted, so a future
|
||||
// server-rendered or inlined first paint has the width already.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; samesite=lax`;
|
||||
},
|
||||
[setOpenProp],
|
||||
);
|
||||
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
if (isMobile) setOpenMobile((current) => !current);
|
||||
else setOpen(!open);
|
||||
}, [isMobile, open, setOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== SIDEBAR_KEYBOARD_SHORTCUT) return;
|
||||
if (!event.metaKey && !event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const value = React.useMemo<SidebarContextValue>(
|
||||
() => ({
|
||||
state: open ? 'expanded' : 'collapsed',
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[open, setOpen, isMobile, openMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={value}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn('group/sidebar-wrapper flex min-h-dvh w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = 'SidebarProvider';
|
||||
|
||||
export const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
collapsible?: 'icon' | 'none';
|
||||
}
|
||||
>(({ side = 'left', collapsible = 'icon', className, children, ...props }, ref) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
side={side}
|
||||
// The Sheet's own close button is suppressed: the sidebar header
|
||||
// carries one that does not overlap the account switcher.
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden sm:max-w-[--sidebar-width]"
|
||||
style={{ '--sidebar-width': SIDEBAR_WIDTH_MOBILE } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Move between the PIG workspaces.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col pb-[var(--safe-bottom)] pt-[var(--safe-top)]">
|
||||
{children}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group relative hidden shrink-0 self-start overflow-hidden bg-sidebar text-sidebar-foreground lg:flex lg:flex-col',
|
||||
side === 'left' ? 'border-r border-sidebar-border' : 'border-l border-sidebar-border',
|
||||
// The whole collapse animation is this one declaration. Width is
|
||||
// driven by data-state, so nothing measures anything in JavaScript.
|
||||
'transition-[width] duration-200 ease-linear',
|
||||
'w-[calc(var(--sidebar-width)+var(--safe-left))] pl-[var(--safe-left)]',
|
||||
'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+var(--safe-left))]',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 'var(--sidebar-offset-top, 0px)',
|
||||
height: 'calc(100dvh - var(--sidebar-offset-top, 0px))',
|
||||
}}
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-side={side}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
export const SidebarTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar, state, isMobile } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('shrink-0 text-muted', className)}
|
||||
aria-label={
|
||||
isMobile ? 'Open navigation' : state === 'expanded' ? 'Collapse sidebar' : 'Expand sidebar'
|
||||
}
|
||||
aria-expanded={isMobile ? undefined : state === 'expanded'}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft className="size-5" aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = 'SidebarTrigger';
|
||||
|
||||
/**
|
||||
* The hit strip along the sidebar's outer edge.
|
||||
*
|
||||
* Wide enough to hit with a mouse without being a visible control, which is
|
||||
* how every editor-style sidebar behaves and how people expect to collapse one
|
||||
* without hunting for the button.
|
||||
*/
|
||||
export const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { toggleSidebar, state } = useSidebar();
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
onClick={toggleSidebar}
|
||||
title={state === 'expanded' ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
className={cn(
|
||||
'absolute inset-y-0 right-0 z-20 hidden w-3 cursor-w-resize transition-colors lg:block',
|
||||
'after:absolute after:inset-y-0 after:right-0 after:w-[2px] hover:after:bg-sidebar-border',
|
||||
'group-data-[state=collapsed]:cursor-e-resize',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarRail.displayName = 'SidebarRail';
|
||||
|
||||
export const SidebarInset = React.forwardRef<HTMLElement, React.ComponentProps<'main'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
// min-w-0 is not optional: this is a flex child holding tables and
|
||||
// tabular-nums figures, and without it the page scrolls sideways.
|
||||
<main ref={ref} className={cn('relative flex min-w-0 flex-1 flex-col', className)} {...props} />
|
||||
),
|
||||
);
|
||||
SidebarInset.displayName = 'SidebarInset';
|
||||
|
||||
export const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarHeader.displayName = 'SidebarHeader';
|
||||
|
||||
export const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarFooter.displayName = 'SidebarFooter';
|
||||
|
||||
export const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden',
|
||||
// A scrollbar inside a 64px rail eats a third of it, and the rail has
|
||||
// nothing that needs scrolling anyway.
|
||||
'group-data-[collapsible=icon]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarContent.displayName = 'SidebarContent';
|
||||
|
||||
export const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarGroup.displayName = 'SidebarGroup';
|
||||
|
||||
export const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80',
|
||||
'transition-[margin,opacity] duration-200 ease-linear',
|
||||
// Pulled up rather than hidden, so the icons above and below do not
|
||||
// jump as the label fades out.
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
|
||||
|
||||
export const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-sidebar="group-content" className={cn('w-full', className)} {...props} />
|
||||
),
|
||||
);
|
||||
SidebarGroupContent.displayName = 'SidebarGroupContent';
|
||||
|
||||
export const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenu.displayName = 'SidebarMenu';
|
||||
|
||||
export const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuItem.displayName = 'SidebarMenuItem';
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full min-h-[44px] items-center gap-3 overflow-hidden rounded-xl px-3 text-left text-sm font-medium outline-none ' +
|
||||
'transition-[background-color,color,width,padding] duration-200 ' +
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ' +
|
||||
'focus-visible:ring-2 focus-visible:ring-sidebar-ring ' +
|
||||
'disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[active=true]:shadow-sm ' +
|
||||
// Collapsed: a square 44px target centred in the 64px rail. The label is
|
||||
// still in the DOM for screen readers; `overflow-hidden` on the pane and
|
||||
// `truncate` here keep it from reflowing during the animation.
|
||||
'group-data-[collapsible=icon]:!size-11 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:!px-0 ' +
|
||||
// `sr-only`, not `hidden`. The label is the button's accessible name, and
|
||||
// removing it from the tree leaves an icon-only control that a screen
|
||||
// reader announces as "button" — the tooltip is a hover affordance and
|
||||
// does not name anything. sr-only takes no layout space, so the icon
|
||||
// still centres in the rail.
|
||||
'group-data-[collapsible=icon]:[&>span:last-child]:sr-only ' +
|
||||
'[&>svg]:size-4 [&>svg]:shrink-0 [&>span]:min-w-0 [&>span]:truncate',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'text-muted',
|
||||
outline: 'border border-sidebar-border bg-sidebar text-muted',
|
||||
},
|
||||
size: {
|
||||
default: '',
|
||||
lg: 'min-h-[52px]',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> &
|
||||
VariantProps<typeof sidebarMenuButtonVariants> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
/** Shown as a tooltip only while the rail is collapsed. */
|
||||
tooltip?: string;
|
||||
}
|
||||
>(({ asChild = false, isActive = false, variant, size, tooltip, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// No tooltip when the label is already visible: a tooltip repeating the text
|
||||
// beside it is noise, and on mobile it fires on tap and eats the navigation.
|
||||
if (!tooltip || state !== 'collapsed' || isMobile) return button;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
SidebarMenuButton.displayName = 'SidebarMenuButton';
|
||||
|
||||
export const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'nums pointer-events-none absolute right-3 top-1/2 h-5 min-w-5 -translate-y-1/2 select-none',
|
||||
'flex items-center justify-center rounded-full bg-surface-2 px-1.5 text-[11px] font-medium text-muted',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
|
||||
|
||||
export function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = true,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { showIcon?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('flex h-11 items-center gap-3 rounded-xl px-3', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon ? <Skeleton className="size-4 shrink-0 rounded-md" /> : null}
|
||||
<Skeleton className="h-4 max-w-[--skeleton-width] flex-1 group-data-[collapsible=icon]:hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn('mx-2 w-auto bg-sidebar-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarSeparator.displayName = 'SidebarSeparator';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-surface-2', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Viewport queries, with the breakpoints named once.
|
||||
*
|
||||
* They were not named once before, and it cost: the Piggy sheet-versus-drawer
|
||||
* switch used `md` (768px) while the shell switched navigation at `lg`
|
||||
* (1024px), so between those two widths a tablet got the desktop side sheet
|
||||
* *and* the phone tab bar, and the sheet slid in underneath it. Anything that
|
||||
* needs "is this the phone layout?" must ask the same question the shell asks.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Tailwind `lg`. Below this the shell shows the tab bar and the nav Sheet. */
|
||||
export const NAV_BREAKPOINT = 1024;
|
||||
|
||||
/**
|
||||
* Tailwind `xl`. The Piggy dock only earns a permanent column once the middle
|
||||
* pane still clears ~640px with both gutters taken: 1280 − 256 − 352 = 672.
|
||||
* At `lg` it would leave 416px, which is narrower than the phone layout.
|
||||
*/
|
||||
export const DOCK_BREAKPOINT = 1280;
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
// Re-read on subscribe: the query may have changed between the initial
|
||||
// state and this effect, and a resize would otherwise be needed to notice.
|
||||
update();
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** True on the phone/small-tablet layout: tab bar, nav in a Sheet, Piggy in a Drawer. */
|
||||
export function useIsMobile(): boolean {
|
||||
return !useMediaQuery(`(min-width: ${NAV_BREAKPOINT}px)`);
|
||||
}
|
||||
|
||||
/** True when there is room for Piggy to sit in a permanent right-hand column. */
|
||||
export function useHasDockRoom(): boolean {
|
||||
return useMediaQuery(`(min-width: ${DOCK_BREAKPOINT}px)`);
|
||||
}
|
||||
@@ -36,6 +36,39 @@
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
|
||||
/*
|
||||
* The application header's height, including the notch it sits under. Both
|
||||
* side panes are sticky beneath it and subtract this from the viewport, so
|
||||
* it has to be one number rather than a magic constant in three files.
|
||||
*/
|
||||
--app-header-h: calc(3.5rem + var(--safe-top));
|
||||
|
||||
/*
|
||||
* The sidebar primitive's palette.
|
||||
*
|
||||
* Aliases, not values. shadcn's sidebar block wants its own `--sidebar-*`
|
||||
* scale; defining literal colours here would be a second palette to keep in
|
||||
* step with the first, and it would not follow the user's accent — which is
|
||||
* written onto this same element at runtime by lib/theme.tsx. Because these
|
||||
* are HSL channel triples pointing at other HSL channel triples, the dark
|
||||
* theme and every accent flow through for free and there is nothing to
|
||||
* duplicate under [data-theme='dark'].
|
||||
*/
|
||||
--sidebar-background: var(--surface);
|
||||
--sidebar-foreground: var(--fg);
|
||||
--sidebar-primary: var(--accent);
|
||||
--sidebar-primary-foreground: var(--accent-on);
|
||||
--sidebar-accent: var(--accent-subtle);
|
||||
--sidebar-accent-foreground: var(--accent-fg);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--accent);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--app-header-h: calc(4rem + var(--safe-top));
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Who is signed in, fetched once.
|
||||
*
|
||||
* Before this, five components each ran their own `useQuery(['me'])`. React
|
||||
* Query deduplicated the *request*, so it looked harmless, but it meant every
|
||||
* consumer separately restated the response shape — and they had already
|
||||
* diverged: some declared `{ id, name }`, some added `isPlatformAdmin`, none
|
||||
* carried `permissions`. A control that cannot see the grants cannot be gated
|
||||
* on them, which is why navigation showed everyone pages that answer 403.
|
||||
*
|
||||
* The query key stays `['me']` so anything still fetching it directly, and any
|
||||
* `invalidateQueries` already written against it, keeps working.
|
||||
*/
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import type { PermissionGrant, Team, TeamRole } from '@pig/core';
|
||||
import { get } from './api';
|
||||
|
||||
export interface Identity {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isPlatformAdmin: boolean;
|
||||
teams: { team: Team; role: TeamRole }[];
|
||||
/** Effective grants, resolved server-side. Never re-derive them here. */
|
||||
permissions: PermissionGrant[];
|
||||
/** How this request authenticated — a session, or an API key. */
|
||||
via: string;
|
||||
}
|
||||
|
||||
export const IDENTITY_QUERY_KEY = ['me'] as const;
|
||||
|
||||
export function useIdentityQuery(): UseQueryResult<Identity, unknown> {
|
||||
return useQuery({
|
||||
queryKey: IDENTITY_QUERY_KEY,
|
||||
queryFn: () => get<Identity>('/api/me'),
|
||||
});
|
||||
}
|
||||
|
||||
const IdentityContext = createContext<Identity | null>(null);
|
||||
|
||||
export function IdentityProvider({
|
||||
identity,
|
||||
children,
|
||||
}: {
|
||||
identity: Identity;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <IdentityContext.Provider value={identity}>{children}</IdentityContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in person. Throws outside the provider rather than returning
|
||||
* `undefined`, because every consumer sits inside the auth gate and a silent
|
||||
* `undefined` reads to `can()` as "no permissions" — a denial that looks like
|
||||
* a policy decision instead of a missing provider.
|
||||
*/
|
||||
export function useIdentity(): Identity {
|
||||
const identity = useContext(IdentityContext);
|
||||
if (!identity) throw new Error('useIdentity must be used inside an IdentityProvider.');
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** For code that may render outside the gate (the sign-in screens). */
|
||||
export function useOptionalIdentity(): Identity | null {
|
||||
return useContext(IdentityContext);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Shell layout state: is the left sidebar collapsed, is the Piggy dock open.
|
||||
*
|
||||
* localStorage only, and deliberately so — do not "fix" this back into a
|
||||
* server mirror. Sidebar-collapsed and dock-open are per-DEVICE preferences by
|
||||
* nature: a 27-inch display and a laptop want different answers, and syncing
|
||||
* them would carry the wrong one across. Theme is not like that and stays
|
||||
* mirrored (see lib/theme.tsx).
|
||||
*
|
||||
* There WAS a mirror here. It PATCHed /api/me/preferences, whose zod schema is
|
||||
* non-strict and accepts only themeMode/accentColor/name/handle/title/timezone
|
||||
* — so every collapse fired an HTTP request and a real UPDATE that dropped the
|
||||
* keys and bumped `users.updatedAt`, and the matching adopt path was always a
|
||||
* no-op. A silent no-op, which is the failure mode this codebase keeps getting
|
||||
* bitten by.
|
||||
*
|
||||
* Reading localStorage during the initial `useState` is early enough: the panes
|
||||
* animate their width, so mounting collapsed shows no flash of the expanded
|
||||
* rail. What is deliberately NOT copied from the theme is the pre-paint inline
|
||||
* script — that script's SHA is pinned in the CSP in three places, and a layout
|
||||
* preference is not worth a production deploy that silently white-flashes if
|
||||
* one of them is missed.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
const STORAGE_SIDEBAR = 'pig.sidebarOpen';
|
||||
const STORAGE_DOCK = 'pig.piggyDockOpen';
|
||||
|
||||
interface LayoutContextValue {
|
||||
/** Expanded (true) or collapsed to the icon rail (false). */
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
dockOpen: boolean;
|
||||
setDockOpen: (open: boolean) => void;
|
||||
toggleDock: () => void;
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextValue | null>(null);
|
||||
|
||||
function readStored(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : raw === 'true';
|
||||
} catch {
|
||||
// Private browsing can throw on access. A default is fine.
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function store(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// Non-fatal: the preference simply does not survive the tab. Nothing else
|
||||
// holds a copy, so there is no inconsistency to repair.
|
||||
}
|
||||
}
|
||||
|
||||
export function LayoutProvider({ children }: { children: ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpenState] = useState(() => readStored(STORAGE_SIDEBAR, true));
|
||||
// Closed by default. An agent panel that opens itself on a first visit,
|
||||
// before anyone has asked for one, takes a third of the window uninvited.
|
||||
const [dockOpen, setDockOpenState] = useState(() => readStored(STORAGE_DOCK, false));
|
||||
|
||||
const setSidebarOpen = useCallback((next: boolean) => {
|
||||
setSidebarOpenState(next);
|
||||
store(STORAGE_SIDEBAR, next);
|
||||
}, []);
|
||||
|
||||
const setDockOpen = useCallback((next: boolean) => {
|
||||
setDockOpenState(next);
|
||||
store(STORAGE_DOCK, next);
|
||||
}, []);
|
||||
|
||||
const toggleDock = useCallback(() => setDockOpen(!dockOpen), [dockOpen, setDockOpen]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ sidebarOpen, setSidebarOpen, dockOpen, setDockOpen, toggleDock }),
|
||||
[sidebarOpen, setSidebarOpen, dockOpen, setDockOpen, toggleDock],
|
||||
);
|
||||
|
||||
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLayout(): LayoutContextValue {
|
||||
const context = useContext(LayoutContext);
|
||||
if (!context) throw new Error('useLayout must be used inside a LayoutProvider.');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The navigation table.
|
||||
*
|
||||
* Lifted out of Shell.tsx because four things now read it — the sidebar, the
|
||||
* phone tab bar, the command palette and the header's page title — and a table
|
||||
* that four consumers each filter differently is a table that ends up
|
||||
* duplicated.
|
||||
*
|
||||
* Visibility is a *capability* question, not a cosmetic one. The convention in
|
||||
* this codebase is to disable a control rather than hide it, so that the
|
||||
* interface tells you the same story regardless of who you are. Navigation is
|
||||
* the exception: a destination someone cannot use is not a disabled control,
|
||||
* it is a page that answers 403, and offering it is worse than omitting it.
|
||||
*/
|
||||
import {
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
MessageCircleMore,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Capability, Team } from '@pig/core';
|
||||
import { canAny, type PermissionIdentity } from './permissions';
|
||||
|
||||
export const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
|
||||
export type NavGroup = (typeof NAV_GROUPS)[number];
|
||||
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string;
|
||||
group: NavGroup;
|
||||
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||
primary?: boolean;
|
||||
/**
|
||||
* Hide the item unless this capability is granted somewhere. Absent means
|
||||
* the page is readable by any member — which includes Settings, where the
|
||||
* appearance controls and Sign out live for everybody, admin or not.
|
||||
*/
|
||||
requires?: Capability;
|
||||
/** Narrows `requires` to one team, where the API enforces one. */
|
||||
requiresTeam?: Team;
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true },
|
||||
{ to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' },
|
||||
{ to: '/calendar', label: 'Calendar', icon: CalendarClock, group: 'Intelligence' },
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
|
||||
{ to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
|
||||
{
|
||||
to: '/imports',
|
||||
label: 'Import',
|
||||
icon: FileSpreadsheet,
|
||||
group: 'Records',
|
||||
requires: 'data:import',
|
||||
},
|
||||
{ to: '/team', label: 'Team', icon: Users, group: 'Control' },
|
||||
{
|
||||
to: '/facts',
|
||||
label: 'Fact review',
|
||||
icon: ShieldCheck,
|
||||
group: 'Control',
|
||||
// The API gates fact review on data:import for the research team
|
||||
// specifically (routes/facts.ts), so the nav has to ask the same question.
|
||||
requires: 'data:import',
|
||||
requiresTeam: 'research',
|
||||
},
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, group: 'Control' },
|
||||
];
|
||||
|
||||
export function visibleNav(identity: PermissionIdentity | undefined): NavItem[] {
|
||||
return NAV.filter((item) => {
|
||||
if (!item.requires) return true;
|
||||
if (!item.requiresTeam) return canAny(identity, item.requires);
|
||||
return Boolean(
|
||||
identity &&
|
||||
identity.permissions.some(
|
||||
(grant) =>
|
||||
grant.capability === item.requires &&
|
||||
(grant.team === null || grant.team === item.requiresTeam),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The nav entry a pathname belongs to, for the header title and active state. */
|
||||
export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined {
|
||||
return items.find((item) =>
|
||||
item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
permissionGranted,
|
||||
type Capability,
|
||||
type GlobalCapability,
|
||||
type PermissionGrant,
|
||||
type Team,
|
||||
@@ -23,3 +24,19 @@ export function can(
|
||||
): boolean {
|
||||
return Boolean(identity && permissionGranted(identity.permissions, capability, team));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Could this person do it on *any* team?"
|
||||
*
|
||||
* Distinct from `can()` on purpose. `can()` asks about a specific team, which
|
||||
* is what a button on a specific record needs. Navigation has no record and no
|
||||
* team yet — the question there is only whether the page could ever be useful
|
||||
* — and answering it by picking an arbitrary team would hide Import from
|
||||
* someone who is an admin of the one team that was not picked.
|
||||
*/
|
||||
export function canAny(
|
||||
identity: PermissionIdentity | undefined,
|
||||
capability: Capability,
|
||||
): boolean {
|
||||
return Boolean(identity && permissionGranted(identity.permissions, capability));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { PiggyChatContext } from '@pig/core';
|
||||
import { ApiError, getSupabase } from './api';
|
||||
|
||||
export interface PiggyChatContext {
|
||||
type: 'account' | 'contact' | 'demand_deal' | 'supply_deal' | 'contract' | 'commitment';
|
||||
id: string;
|
||||
label?: string;
|
||||
}
|
||||
/**
|
||||
* Re-exported from @pig/core rather than declared here. The old local copy was
|
||||
* a fourth definition of a shape that already existed in the relay's zod
|
||||
* schema, the Piggy server's own interface and the model prompt — and widening
|
||||
* it for the docked panel meant widening it in all of them or getting a 400
|
||||
* from whichever hop was missed.
|
||||
*/
|
||||
export type { PiggyChatContext };
|
||||
export {
|
||||
PiggyContextProvider,
|
||||
usePiggyContext,
|
||||
usePiggyCurrentContext,
|
||||
} from './piggy-context';
|
||||
|
||||
export interface PiggyChatTurn {
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* What Piggy is looking at, published by the page and read by the dock.
|
||||
*
|
||||
* The shape itself is `PiggyChatContext` in @pig/core, because it crosses four
|
||||
* process boundaries; this module is only the browser-side plumbing for
|
||||
* deciding *which* context is in force.
|
||||
*
|
||||
* Two ways to answer that, and the precedence between them is the whole point:
|
||||
*
|
||||
* ambient — the dock is open on some page and nobody said otherwise, so the
|
||||
* context is `{ type: 'page', route }` derived from the router. A
|
||||
* page can publish something better with `usePiggyContext`.
|
||||
* explicit — a component passed a context prop, because the user pressed
|
||||
* "Ask Piggy" on a specific row. That is a record context and it
|
||||
* must win: the ambient page context is a guess, and a guess must
|
||||
* never displace the thing the user actually pointed at.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toPiggyPageRoute, type PiggyChatContext } from '@pig/core';
|
||||
|
||||
interface PiggyContextValue {
|
||||
/** What a page has published, if anything. */
|
||||
published: PiggyChatContext | undefined;
|
||||
publish: (context: PiggyChatContext | undefined) => void;
|
||||
}
|
||||
|
||||
const PiggyContextContext = createContext<PiggyContextValue | null>(null);
|
||||
|
||||
export function PiggyContextProvider({ children }: { children: ReactNode }) {
|
||||
const [published, setPublished] = useState<PiggyChatContext | undefined>(undefined);
|
||||
const value = useMemo(() => ({ published, publish: setPublished }), [published]);
|
||||
return <PiggyContextContext.Provider value={value}>{children}</PiggyContextContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a context for as long as this component is mounted.
|
||||
*
|
||||
* Clearing on unmount matters: without it, navigating away from a record page
|
||||
* leaves Piggy still holding that record's id, and it answers questions about
|
||||
* a row that is no longer on screen.
|
||||
*/
|
||||
export function usePiggyContext(value: PiggyChatContext | undefined): void {
|
||||
const context = useContext(PiggyContextContext);
|
||||
const publish = context?.publish;
|
||||
// Serialised rather than passed by reference: call sites build the object
|
||||
// inline, so a reference dependency re-publishes on every render.
|
||||
const key = value ? JSON.stringify(value) : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!publish) return;
|
||||
publish(key ? (JSON.parse(key) as PiggyChatContext) : undefined);
|
||||
return () => publish(undefined);
|
||||
}, [publish, key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The context in force right now: whatever a page published, else the route.
|
||||
*
|
||||
* Safe outside the provider — it falls back to the route alone — because the
|
||||
* unauthenticated screens render without the shell.
|
||||
*/
|
||||
export function usePiggyCurrentContext(): PiggyChatContext {
|
||||
const context = useContext(PiggyContextContext);
|
||||
const { pathname } = useLocation();
|
||||
const route = toPiggyPageRoute(pathname);
|
||||
return context?.published ?? { type: 'page', route };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,11 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { AlertTriangle, Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { AlertTriangle, Lock, Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { can, canAny } from '@/lib/permissions';
|
||||
import {
|
||||
AllocationSheet,
|
||||
type AvailabilityRow,
|
||||
@@ -38,12 +38,45 @@ export function Capacity() {
|
||||
matches?: MatchRow[];
|
||||
defaultGpuHours?: number;
|
||||
}>({ open: false });
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||||
});
|
||||
const me = useIdentity();
|
||||
/*
|
||||
* Two different questions, and conflating them is what F3 exists to prevent.
|
||||
*
|
||||
* Every figure on this page — cost per GPU-hour, break-even, the matcher's
|
||||
* verdict — is now gated on `economics:read`, so without it the page has
|
||||
* nothing to render and every request answers 403. Say so, rather than
|
||||
* showing empty cards and a network error.
|
||||
*
|
||||
* Writing is separate: the allocate and hold buttons post to
|
||||
* `/api/allocations`, which the server authorises on `deal:write` for the
|
||||
* demand team. That is what the button must ask for — not `commitment:write`,
|
||||
* which governs `/api/commitments` and is not reachable from this page.
|
||||
*
|
||||
* `canAny` rather than `can` for the read: read grants are platform-wide by
|
||||
* construction (see READ_CAPABILITIES), so there is no team to name.
|
||||
*/
|
||||
const readable = canAny(me, 'economics:read');
|
||||
const writable = can(me, 'deal:write', 'demand');
|
||||
|
||||
if (!readable) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock className="h-8 w-8" />}
|
||||
title="Capacity economics are restricted"
|
||||
description="Supplier cost and break-even pricing need the economics permission. Ask a platform administrator for supply or demand team membership."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
<header>
|
||||
@@ -190,7 +223,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
|
||||
: `${money(row.breakEvenPriceCents)}/GPU-hr`}
|
||||
</dd>
|
||||
</dl>
|
||||
<Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Demand-team write permission is required' : undefined}>
|
||||
<Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Allocating capacity needs demand-team write access' : undefined}>
|
||||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||||
Allocate or hold
|
||||
</Button>
|
||||
@@ -376,7 +409,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
mutation.data,
|
||||
form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||||
)}
|
||||
title={!writable ? 'Demand-team write permission is required' : undefined}
|
||||
title={!writable ? 'Allocating capacity needs demand-team write access' : undefined}
|
||||
>
|
||||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||||
Allocate this capacity
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
ChevronRight,
|
||||
FileCheck2,
|
||||
FilePlus2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ACCOUNT_SIDES,
|
||||
CONTRACT_STATUSES,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
} from '@pig/core';
|
||||
import { get, money, patch, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { can, canAny } from '@/lib/permissions';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -300,8 +304,39 @@ const EMPTY_FORM: ContractFormState = {
|
||||
metricTargets: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* What the server will actually allow, asked once and answered per side.
|
||||
*
|
||||
* This page shipped without asking at all: every create and save button was
|
||||
* enabled for everyone, while `POST /api/contracts` requires `contract:sign`
|
||||
* for the *specific* side the paper governs — `ensureSidePermission` in
|
||||
* contracts.ts. So a demand-team admin filling in a supply MSA got a filled
|
||||
* form, a working Save button and a 403 after typing forty fields. The whole
|
||||
* point of the shared permission model is that the button and the endpoint
|
||||
* read the same rule; here they were not even asking the same question.
|
||||
*
|
||||
* `disabled`, never hidden. A control that vanishes teaches nothing; one that
|
||||
* is greyed out with a reason tells the reader who to ask.
|
||||
*/
|
||||
function useContractSigning() {
|
||||
const me = useIdentity();
|
||||
const supply = can(me, 'contract:sign', 'supply');
|
||||
const demand = can(me, 'contract:sign', 'demand');
|
||||
return {
|
||||
supply,
|
||||
demand,
|
||||
any: supply || demand,
|
||||
/** `both` is rejected by the server outright, so it is never signable. */
|
||||
forSide: (side: ContractSide) => (side === 'supply' ? supply : side === 'demand' ? demand : false),
|
||||
};
|
||||
}
|
||||
|
||||
const SIGN_DENIED = 'Signing paper on this side needs the contract:sign permission.';
|
||||
|
||||
export function Contracts() {
|
||||
usePageTitle('Contracts');
|
||||
const me = useIdentity();
|
||||
const signing = useContractSigning();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editor, setEditor] = useState<'create' | 'edit' | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -347,6 +382,28 @@ export function Contracts() {
|
||||
}, [contractsQuery.data]);
|
||||
const filtered = Boolean(query || type !== 'all' || side !== 'all');
|
||||
|
||||
// Every negotiated term on this page is behind `book:read` now. Without it
|
||||
// the three queries above all answer 403, so say why rather than rendering
|
||||
// three separate network errors.
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Contracts</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock />}
|
||||
title="Contracts are restricted"
|
||||
description="Reading negotiated terms needs the book permission. Ask a platform administrator for team membership."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -359,6 +416,8 @@ export function Contracts() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!signing.any}
|
||||
title={signing.any ? undefined : SIGN_DENIED}
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setEditor('create');
|
||||
@@ -419,7 +478,7 @@ export function Contracts() {
|
||||
}
|
||||
action={
|
||||
contractsQuery.data?.length ? undefined : (
|
||||
<Button variant="primary" onClick={() => setEditor('create')}>Add governing paper</Button>
|
||||
<Button variant="primary" disabled={!signing.any} title={signing.any ? undefined : SIGN_DENIED} onClick={() => setEditor('create')}>Add governing paper</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -532,8 +591,11 @@ export function Contracts() {
|
||||
<SheetTitle>New contract</SheetTitle>
|
||||
<SheetDescription>Start with what is evidenced in the paper. Unknown terms can stay blank.</SheetDescription>
|
||||
</SheetHeader>
|
||||
{/* Open on a side the reader can actually sign. A supply-only admin
|
||||
landing on the demand default met a dead Save button on a blank
|
||||
form, which reads as breakage rather than as policy. */}
|
||||
<ContractEditor
|
||||
initial={EMPTY_FORM}
|
||||
initial={signing.demand ? EMPTY_FORM : { ...EMPTY_FORM, side: 'supply' }}
|
||||
accounts={accountsQuery.data ?? []}
|
||||
contracts={contractsQuery.data ?? []}
|
||||
onCancel={() => setEditor(null)}
|
||||
@@ -608,6 +670,9 @@ function ContractDetailSheet({
|
||||
|
||||
function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit(): void }) {
|
||||
const chain = [...detail.hierarchy.chain].reverse();
|
||||
// The side of the paper in front of you, not "any side" — editing a supply
|
||||
// MSA is authorised on supply, whatever else you may sign.
|
||||
const maySign = useContractSigning().forSide(detail.contract.side as ContractSide);
|
||||
return (
|
||||
<>
|
||||
<SheetHeader>
|
||||
@@ -638,7 +703,7 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:flex sm:flex-wrap">
|
||||
<Button className="min-h-11 w-full sm:w-auto" type="button" variant="primary" onClick={onEdit}>
|
||||
<Button className="min-h-11 w-full sm:w-auto" type="button" variant="primary" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} onClick={onEdit}>
|
||||
<Pencil aria-hidden /> Edit terms
|
||||
</Button>
|
||||
<PiggyAskButton
|
||||
@@ -773,23 +838,33 @@ function Obligations({ detail }: { detail: ContractDetail }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [kind, setKind] = useState<Obligation['kind']>('renewal_notice');
|
||||
const [dueAt, setDueAt] = useState('');
|
||||
// Obligations are governed by the same `contract:sign` grant as the paper
|
||||
// they hang off — `createObligationMutationDefinition` re-checks the parent
|
||||
// contract's side before it inserts.
|
||||
const maySign = useContractSigning().forSide(detail.contract.side as ContractSide);
|
||||
const save = useMutation({
|
||||
mutationFn: () => post<Obligation>(`/api/contracts/${detail.contract.id}/obligations`, { title, kind, dueAt: new Date(dueAt).toISOString() }),
|
||||
onSuccess: async () => {
|
||||
setAdding(false); setTitle(''); setDueAt('');
|
||||
await queryClient.invalidateQueries({ queryKey: ['contracts', detail.contract.id] });
|
||||
toast.success('Obligation added');
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
const complete = useMutation({
|
||||
mutationFn: (obligation: Obligation) => patch<Obligation>(`/api/contracts/${detail.contract.id}/obligations/${obligation.id}`, { completedAt: obligation.completedAt ? null : new Date().toISOString() }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contracts', detail.contract.id] }),
|
||||
onSuccess: async (obligation) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['contracts', detail.contract.id] });
|
||||
toast.success(obligation.completedAt ? 'Obligation completed' : 'Obligation reopened');
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><h3 className="font-semibold">Dated obligations</h3><p className="text-xs text-muted">Renewal, payment, review and delivery alarms.</p></div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setAdding((value) => !value)}><Plus aria-hidden /> Add</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} onClick={() => setAdding((value) => !value)}><Plus aria-hidden /> Add</Button>
|
||||
</div>
|
||||
{adding ? (
|
||||
<form className="rounded-lg border border-border p-3" onSubmit={(event) => { event.preventDefault(); save.mutate(); }}>
|
||||
@@ -799,12 +874,12 @@ function Obligations({ detail }: { detail: ContractDetail }) {
|
||||
<Field label="Due"><Input required type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} /></Field>
|
||||
</div>
|
||||
{save.isError ? <p className="mt-2 text-sm text-danger">{save.error.message}</p> : null}
|
||||
<div className="mt-3 flex justify-end gap-2"><Button type="button" variant="ghost" onClick={() => setAdding(false)}>Cancel</Button><Button type="submit" variant="primary" disabled={save.isPending}>Save obligation</Button></div>
|
||||
<div className="mt-3 flex justify-end gap-2"><Button type="button" variant="ghost" onClick={() => setAdding(false)}>Cancel</Button><Button type="submit" variant="primary" disabled={save.isPending || !maySign} title={maySign ? undefined : SIGN_DENIED}>Save obligation</Button></div>
|
||||
</form>
|
||||
) : null}
|
||||
{detail.obligations.length === 0 ? <EmptyState icon={<CalendarClock />} title="No obligations recorded" description="An expiry date alone cannot be acted on. Add the notice, review or true-up deadline." /> : detail.obligations.map((obligation) => {
|
||||
const overdue = !obligation.completedAt && new Date(obligation.dueAt) < new Date();
|
||||
return <div key={obligation.id} className={cn('flex items-center gap-3 rounded-lg border p-3', overdue ? 'border-warning/40' : 'border-border')}><button type="button" className={cn('flex size-11 shrink-0 items-center justify-center rounded-full border', obligation.completedAt ? 'border-positive bg-positive/10 text-positive' : 'border-border')} aria-label={obligation.completedAt ? 'Reopen obligation' : 'Complete obligation'} onClick={() => complete.mutate(obligation)}>{obligation.completedAt ? <Check aria-hidden /> : null}</button><div className="min-w-0 flex-1"><p className={cn('truncate font-medium', obligation.completedAt && 'text-muted line-through')}>{obligation.title}</p><p className={cn('text-xs', overdue ? 'text-warning' : 'text-muted')}>{humanise(obligation.kind)} · {shortDate(obligation.dueAt)}{overdue ? ' · overdue' : ''}</p></div></div>;
|
||||
return <div key={obligation.id} className={cn('flex items-center gap-3 rounded-lg border p-3', overdue ? 'border-warning/40' : 'border-border')}><button type="button" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} className={cn('flex size-11 shrink-0 items-center justify-center rounded-full border disabled:opacity-50', obligation.completedAt ? 'border-positive bg-positive/10 text-positive' : 'border-border')} aria-label={obligation.completedAt ? 'Reopen obligation' : 'Complete obligation'} onClick={() => complete.mutate(obligation)}>{obligation.completedAt ? <Check aria-hidden /> : null}</button><div className="min-w-0 flex-1"><p className={cn('truncate font-medium', obligation.completedAt && 'text-muted line-through')}>{obligation.title}</p><p className={cn('text-xs', overdue ? 'text-warning' : 'text-muted')}>{humanise(obligation.kind)} · {shortDate(obligation.dueAt)}{overdue ? ' · overdue' : ''}</p></div></div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -813,6 +888,14 @@ function Obligations({ detail }: { detail: ContractDetail }) {
|
||||
function ContractEditor({ initial, contractId, accounts, contracts, onCancel, onSaved }: { initial: ContractFormState; contractId?: string; accounts: AccountOption[]; contracts: ContractListRow[]; onCancel(): void; onSaved(id: string): void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState(initial);
|
||||
const signing = useContractSigning();
|
||||
/*
|
||||
* Re-evaluated as the side selector changes, because the server does the
|
||||
* same: `ensureSidePermission` is checked against the *pending* side of the
|
||||
* update, not the stored one. A demand admin retyping a paper's side to
|
||||
* supply is refused, so the Save button must go dead the moment they do.
|
||||
*/
|
||||
const maySign = signing.forSide(form.side);
|
||||
const set = <Key extends keyof ContractFormState>(key: Key, value: ContractFormState[Key]) => setForm((current) => ({ ...current, [key]: value }));
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -824,8 +907,10 @@ function ContractEditor({ initial, contractId, accounts, contracts, onCancel, on
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts', saved.id] }),
|
||||
]);
|
||||
toast.success(contractId ? 'Contract terms saved' : 'Contract created');
|
||||
onSaved(saved.id);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
const parentOptions = contracts.filter(({ contract }) => contract.id !== contractId && contract.accountId === form.accountId && contract.side === form.side);
|
||||
|
||||
@@ -899,9 +984,15 @@ function ContractEditor({ initial, contractId, accounts, contracts, onCancel, on
|
||||
|
||||
<Field label="Internal notes"><Textarea value={form.notes} onChange={(event) => set('notes', event.target.value)} placeholder="Do not use this in place of negotiated terms." /></Field>
|
||||
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
|
||||
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-border bg-surface/95 py-3 backdrop-blur">
|
||||
<div className="sticky bottom-0 flex flex-col gap-2 border-t border-border bg-surface/95 py-3 backdrop-blur sm:flex-row sm:justify-end">
|
||||
{maySign ? null : (
|
||||
<p role="status" className="min-w-0 flex-1 text-sm text-muted sm:self-center">
|
||||
You may not sign {humanise(form.side)}-side paper. Change the market side, or ask a
|
||||
{form.side === 'supply' ? ' supply' : ' demand'}-team administrator.
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : contractId ? 'Save terms' : 'Create contract'}</Button>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending || !maySign} title={maySign ? undefined : SIGN_DENIED}>{save.isPending ? 'Saving…' : contractId ? 'Save terms' : 'Create contract'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
/**
|
||||
* Learn — two tracks, one of which a stranger with the share code can see.
|
||||
*
|
||||
* The page has to render correctly for two very different callers, and the
|
||||
* difference is decided by the server, never by anything held here:
|
||||
*
|
||||
* a member — `GET /api/learn` succeeds and returns all three tracks.
|
||||
* a code-holder — that call answers 401, so the page falls back to the code
|
||||
* gate, and afterwards reads `GET /api/learn/public`, which
|
||||
* returns platform walkthroughs and nothing else.
|
||||
*
|
||||
* Deriving the mode from the 401 rather than from an identity hook is what
|
||||
* lets this file work both inside the authenticated shell (where it lives
|
||||
* today) and outside it, which is what the anonymous `/learn` route needs. It
|
||||
* also means the browser is never the thing deciding what a code-holder may
|
||||
* see — it asks, and the API answers with a smaller set.
|
||||
*
|
||||
* **The locked Concepts panel is deliberate, not an oversight.** A code-holder
|
||||
* is shown that supply and demand material exists and is behind sign-in. That
|
||||
* was an explicit product decision: the point of the page for an outsider is
|
||||
* partly to advertise the rest of it.
|
||||
*
|
||||
* Nothing here builds an embed URL. Every `iframe src` arrives from the API
|
||||
* already resolved through the host allowlist in `@pig/core`; a resource the
|
||||
* server could not resolve is not in the response at all. Concatenating a URL
|
||||
* in this file would reintroduce exactly the hole the allowlist closes.
|
||||
*/
|
||||
import { useCallback, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { GraduationCap, Lock, Play, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
LEARN_TRACKS,
|
||||
LEARN_TRACK_DESCRIPTIONS,
|
||||
LEARN_TRACK_LABELS,
|
||||
LEARN_VISIBILITIES,
|
||||
formatLearnDuration,
|
||||
toPiggyPageRoute,
|
||||
type LearnTrack,
|
||||
type LearnVisibility,
|
||||
} from '@pig/core';
|
||||
import { ApiError, api, get } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface LearnResourceView {
|
||||
id: string;
|
||||
track: LearnTrack;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
provider: string;
|
||||
visibility: LearnVisibility;
|
||||
durationSeconds: number | null;
|
||||
sortOrder: number;
|
||||
publishedAt: string;
|
||||
embedUrl: string;
|
||||
watchUrl: string;
|
||||
}
|
||||
|
||||
interface MemberFeed {
|
||||
tracks: Record<LearnTrack, LearnResourceView[]>;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
interface PublicFeed {
|
||||
track: LearnTrack;
|
||||
expiresAt: string;
|
||||
resources: LearnResourceView[];
|
||||
lockedTracks: LearnTrack[];
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage, not localStorage.
|
||||
*
|
||||
* The code is shared, often over a shoulder or in a call, and the token it
|
||||
* mints is a session's worth of access to marketing material. Persisting it
|
||||
* across browser restarts on a machine that may not be the holder's is the
|
||||
* wrong default; retyping a passphrase they were given is not a hardship.
|
||||
*/
|
||||
const TOKEN_KEY = 'pig.learn.token';
|
||||
|
||||
const CONCEPT_TRACKS = LEARN_TRACKS.filter((track) => track !== 'platform');
|
||||
|
||||
export function Learn() {
|
||||
usePageTitle('Learn');
|
||||
// The dock's route vocabulary is a closed set in @pig/core and does not yet
|
||||
// carry '/learn'; until it does this resolves to '/', which is the
|
||||
// documented fallback and strictly better than not publishing at all.
|
||||
usePiggyContext({ type: 'page', route: toPiggyPageRoute('/learn'), label: 'Learn' });
|
||||
|
||||
const [token, setToken] = useState<string | null>(() => readToken());
|
||||
const [playing, setPlaying] = useState<LearnResourceView | null>(null);
|
||||
|
||||
const member = useQuery({
|
||||
queryKey: ['learn', 'member'],
|
||||
queryFn: () => get<MemberFeed>('/api/learn'),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Only relevant once the member read has actually been refused. Firing it
|
||||
// speculatively would put a 401 in the console on every member's first load.
|
||||
const locked = member.error instanceof ApiError && member.error.needsSignIn;
|
||||
|
||||
const publicFeed = useQuery({
|
||||
queryKey: ['learn', 'public', token],
|
||||
enabled: locked && Boolean(token),
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/learn/public', {
|
||||
headers: { authorization: `Bearer ${token ?? ''}` },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
// The code was rotated, or the token expired. Drop it and show the
|
||||
// gate again rather than leaving a dead session on screen.
|
||||
clearToken();
|
||||
setToken(null);
|
||||
throw new ApiError('That access has expired.', 401, 'learn_token_expired');
|
||||
}
|
||||
if (!response.ok) throw new ApiError('Could not load the Learn library.', response.status);
|
||||
return (await response.json()) as PublicFeed;
|
||||
},
|
||||
});
|
||||
|
||||
const play = useCallback((resource: LearnResourceView) => setPlaying(resource), []);
|
||||
|
||||
if (member.isLoading) return <LearnSkeleton />;
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<>
|
||||
<CodeHolderView
|
||||
token={token}
|
||||
feed={publicFeed.data ?? null}
|
||||
isLoading={publicFeed.isFetching}
|
||||
onUnlocked={(minted) => {
|
||||
writeToken(minted);
|
||||
setToken(minted);
|
||||
}}
|
||||
onPlay={play}
|
||||
/>
|
||||
<PlayerDialog resource={playing} onClose={() => setPlaying(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (member.error || !member.data) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Learn is unavailable"
|
||||
description={
|
||||
member.error instanceof Error ? member.error.message : 'Could not load the library.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MemberView feed={member.data} onPlay={play} />
|
||||
<PlayerDialog resource={playing} onClose={() => setPlaying(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- member view
|
||||
|
||||
function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResourceView) => void }) {
|
||||
const [concept, setConcept] = useState<string>(CONCEPT_TRACKS[0] ?? 'supply');
|
||||
const total = LEARN_TRACKS.reduce((sum, track) => sum + (feed.tracks[track]?.length ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-8">
|
||||
<PageHeader
|
||||
eyebrow="Curriculum"
|
||||
title="Learn"
|
||||
description="How this market works, and how PIG works. Videos are shared by the team; anything on the Platform track can be sent to someone without an account."
|
||||
aside={<Badge tone="neutral">{total} video{total === 1 ? '' : 's'}</Badge>}
|
||||
/>
|
||||
|
||||
{feed.canManage ? <AddResource /> : null}
|
||||
|
||||
<section aria-labelledby="learn-concepts" className="flex min-w-0 flex-col gap-3">
|
||||
<SectionHeading
|
||||
id="learn-concepts"
|
||||
title="Concepts"
|
||||
description="Market fundamentals for each side of the book. Members only."
|
||||
/>
|
||||
<Tabs value={concept} onValueChange={setConcept} className="min-w-0">
|
||||
{/*
|
||||
The primitive is stock shadcn, so it carries shadcn's tokens and a
|
||||
fixed `h-9`. Both are wrong here and both are overridden rather
|
||||
than fixed in the primitive, which other pages depend on: `bg-muted`
|
||||
is a TEXT colour in PIG's palette and paints the strip as a pale
|
||||
slab in dark mode, and a 36px row cannot hold a 44px touch target.
|
||||
*/}
|
||||
<TabsList className="h-auto w-full justify-start gap-1 overflow-x-auto bg-surface-2 p-1 text-muted sm:w-auto">
|
||||
{CONCEPT_TRACKS.map((track) => (
|
||||
<TabsTrigger
|
||||
key={track}
|
||||
value={track}
|
||||
className="min-h-[44px] shrink-0 px-4 data-[state=active]:bg-surface data-[state=active]:text-fg"
|
||||
>
|
||||
{LEARN_TRACK_LABELS[track]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{CONCEPT_TRACKS.map((track) => (
|
||||
<TabsContent key={track} value={track} className="min-w-0">
|
||||
<p className="mb-3 text-sm text-muted">{LEARN_TRACK_DESCRIPTIONS[track]}</p>
|
||||
<ResourceGrid
|
||||
resources={feed.tracks[track] ?? []}
|
||||
canManage={feed.canManage}
|
||||
emptyTitle={`No ${LEARN_TRACK_LABELS[track].toLowerCase()} material yet`}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="learn-platform" className="flex min-w-0 flex-col gap-3">
|
||||
<SectionHeading
|
||||
id="learn-platform"
|
||||
title="Platform"
|
||||
description={LEARN_TRACK_DESCRIPTIONS.platform}
|
||||
/>
|
||||
<ResourceGrid
|
||||
resources={feed.tracks.platform ?? []}
|
||||
canManage={feed.canManage}
|
||||
emptyTitle="No walkthroughs yet"
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- code-holder view
|
||||
|
||||
function CodeHolderView({
|
||||
token,
|
||||
feed,
|
||||
isLoading,
|
||||
onUnlocked,
|
||||
onPlay,
|
||||
}: {
|
||||
token: string | null;
|
||||
feed: PublicFeed | null;
|
||||
isLoading: boolean;
|
||||
onUnlocked: (token: string) => void;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-8">
|
||||
<PageHeader
|
||||
eyebrow="Prime Intellect Growth"
|
||||
title="Learn"
|
||||
description={
|
||||
token
|
||||
? 'Product walkthroughs and demos of PIG.'
|
||||
: 'Product walkthroughs and demos of PIG. Enter the code you were given to watch them.'
|
||||
}
|
||||
/>
|
||||
|
||||
{!token ? <CodeGate onUnlocked={onUnlocked} /> : null}
|
||||
|
||||
{token ? (
|
||||
<section aria-labelledby="learn-platform-public" className="flex min-w-0 flex-col gap-3">
|
||||
<SectionHeading
|
||||
id="learn-platform-public"
|
||||
title="Platform"
|
||||
description={LEARN_TRACK_DESCRIPTIONS.platform}
|
||||
/>
|
||||
{isLoading && !feed ? (
|
||||
<CardGridSkeleton />
|
||||
) : (
|
||||
<ResourceGrid
|
||||
resources={feed?.resources ?? []}
|
||||
canManage={false}
|
||||
emptyTitle="Nothing published yet"
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
Shown to a code-holder on purpose: they should know the concept
|
||||
material exists and what it would take to reach it. The server never
|
||||
sends a single row of it, so this panel is a signpost, not a redaction.
|
||||
*/}
|
||||
<section aria-labelledby="learn-locked" className="flex min-w-0 flex-col gap-3">
|
||||
<SectionHeading
|
||||
id="learn-locked"
|
||||
title="Concepts"
|
||||
description="Supply and demand fundamentals, for the go-to-market team."
|
||||
/>
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Lock className="size-6" aria-hidden />}
|
||||
title="Concept training is for members"
|
||||
description="How capacity is sourced and priced, and how compute is sold and renewed. Sign in with your PIG account to watch these."
|
||||
action={
|
||||
<Button variant="primary" asChild>
|
||||
<a href="/">Sign in</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeGate({ onUnlocked }: { onUnlocked: (token: string) => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
const unlock = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
const response = await fetch('/api/learn/access', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: value }),
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
token?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
};
|
||||
if (!response.ok || !body.token) {
|
||||
throw new ApiError(body.error ?? 'That code is not valid.', response.status, body.code);
|
||||
}
|
||||
return body.token;
|
||||
},
|
||||
onSuccess: (minted) => {
|
||||
onUnlocked(minted);
|
||||
toast.success('Unlocked. Here are the product walkthroughs.');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : 'That code is not valid.');
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
unlock.mutate(trimmed);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<form onSubmit={submit} className="flex min-w-0 flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="learn-code" className="text-sm font-medium">
|
||||
Access code
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Whoever shared this page with you has the code.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="learn-code"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="Enter the code"
|
||||
className="min-w-0 sm:flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={unlock.isPending || !code.trim()}>
|
||||
{unlock.isPending ? 'Checking…' : 'Unlock'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- pieces
|
||||
|
||||
function ResourceGrid({
|
||||
resources,
|
||||
canManage,
|
||||
emptyTitle,
|
||||
onPlay,
|
||||
}: {
|
||||
resources: LearnResourceView[];
|
||||
canManage: boolean;
|
||||
emptyTitle: string;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<GraduationCap className="size-6" aria-hidden />}
|
||||
title={emptyTitle}
|
||||
description="Paste a video link to start the collection."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{resources.map((resource) => (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
canManage={canManage}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceCard({
|
||||
resource,
|
||||
canManage,
|
||||
onPlay,
|
||||
}: {
|
||||
resource: LearnResourceView;
|
||||
canManage: boolean;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
const duration = formatLearnDuration(resource.durationSeconds);
|
||||
|
||||
return (
|
||||
<Card className="flex min-w-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlay(resource)}
|
||||
className="tap flex min-w-0 flex-1 flex-col gap-2 rounded-2xl p-4 text-left transition-colors hover:bg-surface-2 sm:p-5"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
{/* break-words, not truncate: a title is the only way to tell two
|
||||
walkthroughs apart, and an unbroken word at 393px is what drags
|
||||
the whole page sideways. */}
|
||||
<h3 className="min-w-0 break-words font-semibold leading-tight">{resource.title}</h3>
|
||||
{duration ? (
|
||||
<Badge tone="neutral" className="nums shrink-0">
|
||||
{duration}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{resource.summary ? (
|
||||
<p className="min-w-0 break-words text-sm leading-6 text-muted">{resource.summary}</p>
|
||||
) : null}
|
||||
<span className="mt-auto inline-flex items-center gap-1.5 pt-2 text-sm font-medium text-accent-fg">
|
||||
<Play className="size-4" aria-hidden />
|
||||
Play
|
||||
</span>
|
||||
</button>
|
||||
{canManage ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-t border-border px-4 py-2 sm:px-5">
|
||||
{resource.visibility === 'code' ? (
|
||||
<Badge tone="accent">Shared by code</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Members only</Badge>
|
||||
)}
|
||||
<ArchiveButton resource={resource} />
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveButton({ resource }: { resource: LearnResourceView }) {
|
||||
const queryClient = useQueryClient();
|
||||
const archive = useMutation({
|
||||
mutationFn: () => api<unknown>(`/api/learn/resources/${resource.id}`, { method: 'DELETE' }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['learn'] });
|
||||
toast.success(`Archived “${resource.title}”.`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Could not archive that video.');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => archive.mutate()}
|
||||
disabled={archive.isPending}
|
||||
aria-label={`Archive ${resource.title}`}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
Archive
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin add form.
|
||||
*
|
||||
* Track and visibility are plain selects rather than a clever control because
|
||||
* the pairing rule between them is enforced by the API and the database, not
|
||||
* here — so the UI's job is to be legible, and disabling the option would only
|
||||
* hide a refusal the server is going to make anyway with a better message.
|
||||
*/
|
||||
function AddResource() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [track, setTrack] = useState<LearnTrack>('platform');
|
||||
const [visibility, setVisibility] = useState<LearnVisibility>('code');
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [minutes, setMinutes] = useState('');
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => {
|
||||
const parsedMinutes = Number(minutes);
|
||||
return api<LearnResourceView>('/api/learn/resources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
track,
|
||||
visibility,
|
||||
title: title.trim(),
|
||||
summary: summary.trim() || undefined,
|
||||
url: url.trim(),
|
||||
durationSeconds:
|
||||
minutes.trim() && Number.isFinite(parsedMinutes) && parsedMinutes > 0
|
||||
? Math.round(parsedMinutes * 60)
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: async (created) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['learn'] });
|
||||
setOpen(false);
|
||||
setTitle('');
|
||||
setSummary('');
|
||||
setUrl('');
|
||||
setMinutes('');
|
||||
toast.success(`Added “${created.title}”.`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Could not add that video.');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-w-0">
|
||||
<Button variant="primary" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
Add a video
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[90dvh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a video</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste a share link from video.karti.ai. Other hosts are rejected until they are
|
||||
added to the allowlist.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="Share link" htmlFor="learn-url">
|
||||
<Input
|
||||
id="learn-url"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://video.karti.ai/s/…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Title" htmlFor="learn-title">
|
||||
<Input
|
||||
id="learn-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Summary" htmlFor="learn-summary">
|
||||
<Input
|
||||
id="learn-summary"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder="What someone learns from it"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<Field label="Track" htmlFor="learn-track">
|
||||
<NativeSelect
|
||||
id="learn-track"
|
||||
value={track}
|
||||
onChange={(value) => setTrack(value as LearnTrack)}
|
||||
options={LEARN_TRACKS.map((value) => ({
|
||||
value,
|
||||
label: LEARN_TRACK_LABELS[value],
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Visibility" htmlFor="learn-visibility">
|
||||
<NativeSelect
|
||||
id="learn-visibility"
|
||||
value={visibility}
|
||||
onChange={(value) => setVisibility(value as LearnVisibility)}
|
||||
options={LEARN_VISIBILITIES.map((value) => ({
|
||||
value,
|
||||
label: value === 'code' ? 'Anyone with the code' : 'Members only',
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Length in minutes" htmlFor="learn-minutes">
|
||||
<Input
|
||||
id="learn-minutes"
|
||||
value={minutes}
|
||||
onChange={(event) => setMinutes(event.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={create.isPending || !url.trim() || !title.trim()}
|
||||
>
|
||||
{create.isPending ? 'Adding…' : 'Add video'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label htmlFor={htmlFor} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-11 w-full min-w-0 rounded-lg border border-border bg-surface px-3 text-base text-fg focus-visible:border-accent"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The player.
|
||||
*
|
||||
* `src` comes from the API, already rebuilt from the allowlist — this file
|
||||
* never concatenates one. The sandbox keeps the frame from navigating the top
|
||||
* window or opening downloads; `allow-same-origin` is safe and necessary here
|
||||
* because the frame is cross-origin, so "same origin" means the video host's
|
||||
* own, not PIG's.
|
||||
*/
|
||||
function PlayerDialog({
|
||||
resource,
|
||||
onClose,
|
||||
}: {
|
||||
resource: LearnResourceView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={resource !== null} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto">
|
||||
{resource ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-words pr-10">{resource.title}</DialogTitle>
|
||||
{resource.summary ? (
|
||||
<DialogDescription className="break-words">{resource.summary}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
<div className="aspect-video w-full min-w-0 overflow-hidden rounded-lg bg-surface-2">
|
||||
<iframe
|
||||
key={resource.id}
|
||||
src={resource.embedUrl}
|
||||
title={resource.title}
|
||||
className="size-full border-0"
|
||||
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href={resource.watchUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="tap inline-flex min-h-[44px] items-center text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
Open on video.karti.ai
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
aside,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
aside?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
{aside ? <div className="shrink-0">{aside}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<h2 id={id} className="text-sm font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnSkeleton() {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<Skeleton className="h-9 w-40 rounded-lg" />
|
||||
<CardGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardGridSkeleton() {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[0, 1, 2].map((key) => (
|
||||
<Skeleton key={key} className="h-40 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- token
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return window.sessionStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
// Private-mode Safari throws on storage access. A code-holder who has to
|
||||
// retype the code is a worse experience than a crash is a bug.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* See readToken. */
|
||||
}
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
try {
|
||||
window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* See readToken. */
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,22 @@ export default {
|
||||
DEFAULT: 'hsl(var(--danger))',
|
||||
foreground: 'hsl(0 0% 100%)',
|
||||
},
|
||||
/*
|
||||
* The sidebar primitive's scale. Every one of these resolves, through
|
||||
* the --sidebar-* aliases in index.css, to a variable already defined
|
||||
* above — so this is a naming layer for shadcn's block, not a second
|
||||
* palette that can drift from the first.
|
||||
*/
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
positive: 'hsl(var(--positive))',
|
||||
warning: 'hsl(var(--warning))',
|
||||
danger: 'hsl(var(--danger))',
|
||||
|
||||
Reference in New Issue
Block a user