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:
@@ -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 };
|
||||
Reference in New Issue
Block a user