4 sections
User menus
The account menu: who is signed in, their workspace, their settings and the way out.
Account menu at the foot of the sidebar
user-menus-01The signed-in row at the bottom of a sidebar — face, name and email — opening who is signed in with their role, profile, notifications with the unread count, settings, help and Sign out.
import * as React from "react"
import Link from "next/link"
import { CalendarIcon, FolderIcon, LayoutDashboardIcon, ListIcon, MessageCircleIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { WorkspaceMark } from "@/components/ui/workspace-switcher"
import { SidebarAccount } from "./user-menus-01.client"
import { WORKSPACE, account, signOut } from "./user-menus-01.data"
/** The sidebar the account row sits in: the Projects dashboard's own pages, Tasks the current one. */
const NAV: { title: string; href: string; icon: LucideIcon; current?: true }[] = [
{ title: "Overview", href: "/projects", icon: LayoutDashboardIcon },
{ title: "Tasks", href: "/projects/tasks", icon: ListIcon, current: true },
{ title: "Calendar", href: "/projects/calendar", icon: CalendarIcon },
{ title: "Chat", href: "/projects/chat", icon: MessageCircleIcon },
{ title: "Files", href: "/projects/files", icon: FolderIcon },
]
/**
* The account menu where most apps keep it: at the foot of the sidebar. The
* sidebar is drawn around it — the workspace, its pages with the current one
* filled — so the row sits where it will once installed, and the page beside
* it is where the menu opens from `md`.
*
* A server component: it reads who is signed in through
* `./user-menus-01.data`, and hands the island the account and `signOutAction`,
* a server function over the kit's auth adapter.
*/
export function UserMenus01({ defaultOpen = false }: { defaultOpen?: boolean }) {
const titleId = React.useId()
const who = account()
async function signOutAction() {
"use server"
return signOut()
}
return (
<section
data-section="user-menus-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Account menu</h2>
</CardTitle>
<CardDescription>At the foot of the sidebar: who is signed in, their settings and the way out</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="grid min-h-[27rem] md:grid-cols-[16rem_minmax(0,1fr)]">
<div className="flex flex-col gap-4 bg-sidebar p-2 text-sidebar-foreground md:border-e md:border-border">
<div className="flex items-center gap-2 px-2 pt-2">
<WorkspaceMark workspace={{ id: "work", name: WORKSPACE }} />
<span className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-sm font-semibold">{WORKSPACE}</span>
<span className="truncate text-xs text-muted-foreground">Projects</span>
</span>
</div>
<nav aria-label={WORKSPACE}>
<ul className="flex flex-col gap-0.5">
{NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
aria-current={item.current ? "page" : undefined}
className={cn(
"flex h-8 items-center gap-2 rounded-md px-2 text-sm outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring",
item.current && "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
)}
>
<item.icon aria-hidden="true" className="size-4 shrink-0" />
{item.title}
</Link>
</li>
))}
</ul>
</nav>
<div className="mt-auto border-t border-border pt-2">
{who ? (
<SidebarAccount account={who} onSignOut={signOutAction} defaultOpen={defaultOpen} />
) : (
// Nobody signed in: the foot of the sidebar is the way in.
<Link href="/sign-in" className={cn(buttonVariants({ variant: "outline" }), "w-full")}>
Sign in
</Link>
)}
</div>
</div>
<div className="hidden flex-col gap-1 p-6 md:flex">
<p className="type-eyebrow">{WORKSPACE}</p>
<p className="type-display text-xl">Tasks</p>
<p className="text-sm text-muted-foreground">Everything assigned to you, soonest due first.</p>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default UserMenus01"use client"
import * as React from "react"
import Link from "next/link"
import { BellIcon, ChevronsUpDownIcon, LifeBuoyIcon, LogOutIcon, SettingsIcon, UserIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Callout } from "@/components/ui/callout"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Account, Result } from "./user-menus-01.data"
export type SidebarAccountProps = {
account: Account
/** The sign-out action: a server function returning a `Result`. */
onSignOut: () => Promise<Result<{ signedOut: true }>>
/** Opens the menu on first render; the preview uses it, an install leaves it off. */
defaultOpen?: boolean
}
/**
* The account row at the foot of a sidebar and the menu it opens: the face,
* the name and the email on the row; in the menu, who is signed in and their
* role, their profile, notifications with the unread count, settings, help,
* and Sign out. The kit's DropdownMenu gives it the whole keyboard — Enter,
* Space or the arrows open it, the arrows move, a letter jumps to the item
* it starts, Escape closes it and the focus goes back to the row. From `md`
* the menu opens beside the sidebar; on a phone it opens above the row.
* Signing out goes through the action it is handed; the line under the row
* says it happened, and a refusal would show where the row is.
*/
export function SidebarAccount({ account, onSignOut, defaultOpen = false }: SidebarAccountProps) {
const wide = useMediaQuery("(min-width: 48rem)")
const [message, setMessage] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
// True while a sign-out is on its way: the trigger is busy and the item waits.
const [pending, setPending] = React.useState(false)
const initials = getInitials(account.name)
async function signOut() {
if (pending) return
setRefusal(null)
setMessage("Signing out…")
setPending(true)
let result: Result<{ signedOut: true }>
try {
result = await onSignOut()
} catch {
result = { ok: false, error: { code: "network", message: "Sign out did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (result.ok) {
setMessage(`Signed out of ${account.workspace}. Your app would open its sign-in page now.`)
} else {
// Never a stale "Signed out" beside the refusal.
setMessage("")
setRefusal(result.error.message)
}
}
return (
<div className="flex flex-col">
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
aria-label={`Account menu for ${account.name}`}
aria-busy={pending || undefined}
className="flex w-full items-center gap-2.5 rounded-md p-2 text-left outline-none hover:bg-sidebar-accent focus-ring data-popup-open:bg-sidebar-accent"
>
<Avatar aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="truncate text-sm font-medium">{account.name}</span>
<span className="truncate text-xs text-muted-foreground">{account.email}</span>
</span>
<ChevronsUpDownIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent side={wide ? "inline-end" : "top"} align={wide ? "end" : "start"} sideOffset={8} className="w-64">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2.5 px-1.5 py-1.5 text-foreground">
<Avatar aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{account.name}</span>
<Badge variant="secondary" className="shrink-0">
{account.role}
</Badge>
</span>
<span className="truncate text-xs font-normal text-muted-foreground">{account.email}</span>
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem render={<Link href="/profile" />}>
<UserIcon aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/notifications" />}>
<BellIcon aria-hidden="true" />
Notifications
{account.unread > 0 ? <DropdownMenuShortcut className="tracking-normal tabular-nums">{account.unread} new</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings" />}>
<SettingsIcon aria-hidden="true" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/support/help" />}>
<LifeBuoyIcon aria-hidden="true" />
Help centre
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={pending} onClick={() => void signOut()}>
<LogOutIcon aria-hidden="true" />
{pending ? "Signing out…" : "Sign out"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{refusal ? (
<Callout variant="danger" role="alert" title="Could not sign you out" className="mt-2">
{refusal}
</Callout>
) : null}
{/* Always mounted, so a sign-out is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="px-2 text-xs text-pretty text-muted-foreground [&:not(:empty)]:mt-2">
{message}
</p>
</div>
)
}/**
* What `user-menus-01` reads: who is signed in — the reader, from
* `team-projects`: the first member, the workspace's owner — with their face,
* email and role, the workspace they are in, and how many notifications they
* have not read (`db.notifications`); and the one thing the menu changes,
* signing out.
*
* `signOut` is `team-projects`' own: it goes through the kit's
* `mockAuthAdapter` — a stub that clears its own session and nothing else —
* and always answers with a `Result`, an adapter that throws coming back as a
* refusal. Swap the adapter for your auth backend and the menu signs out for
* real; the section never needs to change.
*
* Selectors only, reading `db` inside functions, and the one server action,
* all run on the server.
*/
import { reader, signOut } from "@/lib/dashboards/projects/team"
import { db, type Result } from "@/lib/sample-data"
export { signOut }
export type { Result }
/** The workspace the menu belongs to: the Projects dashboard's brand. */
export const WORKSPACE = "Northwind Work"
export type Account = { name: string; email: string; role: string; avatarUrl: string; workspace: string; unread: number }
/** The signed-in member and what the menu says about them; null when nobody is. */
export function account(): Account | null {
const who = reader()
if (!who) return null
return {
name: who.name,
email: who.email,
role: who.role,
avatarUrl: who.avatarUrl,
workspace: WORKSPACE,
unread: db.notifications.all().filter((notification) => !notification.read).length,
}
}$
npx shadcn@latest add @vibra/user-menus-01Header avatar menu with a theme switch
user-menus-02A face at the end of a header bar opening who is signed in, profile, billing and settings, the theme as Light, Dark or System, and Sign out.
import * as React from "react"
import Link from "next/link"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HeaderAccount } from "./user-menus-02.client"
import { WORKSPACE, account, signOut, today } from "./user-menus-02.data"
/**
* The account menu at the end of a header bar: a face that opens who is
* signed in, their profile, billing and settings, the theme, and the way
* out. The header is drawn around it — the trail to the page on the left —
* over the top of the page it belongs to, so the face sits where it will
* once installed and the menu opens down over the page.
*
* A server component: it reads who is signed in through
* `./user-menus-02.data`, and hands the island the account and
* `signOutAction`, a server function over the kit's auth adapter.
*/
export function UserMenus02({ defaultOpen = false }: { defaultOpen?: boolean }) {
const titleId = React.useId()
const who = account()
async function signOutAction() {
"use server"
return signOut()
}
return (
<section
data-section="user-menus-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Header account menu</h2>
</CardTitle>
<CardDescription>A face at the end of the header, with the theme a choice inside it</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="flex min-h-[28rem] flex-col">
<div className="flex h-14 items-center gap-3 border-b border-border px-4 md:px-6">
<Breadcrumb className="min-w-0">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/projects" />}>{WORKSPACE}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ms-auto">
{who ? (
<HeaderAccount account={who} workspace={WORKSPACE} onSignOut={signOutAction} defaultOpen={defaultOpen} />
) : (
// Nobody signed in: the end of the header is the way in.
<Link href="/sign-in" className={buttonVariants({ variant: "outline", size: "sm" })}>
Sign in
</Link>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-4 md:p-6">
<p className="type-display text-xl">Overview</p>
<p className="text-sm text-muted-foreground">{today()}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default UserMenus02"use client"
import * as React from "react"
import Link from "next/link"
import { useTheme } from "next-themes"
import { CreditCardIcon, LogOutIcon, MonitorIcon, MoonIcon, SettingsIcon, SunIcon, UserIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Account, Result } from "./user-menus-02.data"
const THEMES = [
{ value: "light", label: "Light", icon: SunIcon },
{ value: "dark", label: "Dark", icon: MoonIcon },
{ value: "system", label: "System", icon: MonitorIcon },
] as const
// The theme lives in the browser, so the first paint must not depend on it:
// false through the server render and hydration, true from the commit on.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false
export type HeaderAccountProps = {
account: Account
workspace: string
/** The sign-out action: a server function returning a `Result`. */
onSignOut: () => Promise<Result<{ signedOut: true }>>
/** Opens the menu on first render; the preview uses it, an install leaves it off. */
defaultOpen?: boolean
}
/**
* The face at the end of a header bar and the menu it opens: who is signed
* in, their profile, billing and settings, the theme as three choices —
* Light, Dark, System, the current one ticked, bound to the same store the
* kit's ThemeToggle writes — and Sign out. A theme is picked in place and
* the menu stays open to show it; everything else closes it. The kit's
* DropdownMenu gives it the whole keyboard, the focus back on the face when
* it closes. What changed is said in a status line beside the face.
*/
export function HeaderAccount({ account, workspace, onSignOut, defaultOpen = false }: HeaderAccountProps) {
const { theme, setTheme } = useTheme()
const mounted = React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
const [message, setMessage] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
// True while a sign-out is on its way: the trigger is busy and the item waits.
const [pending, setPending] = React.useState(false)
const current = mounted && theme ? theme : "system"
const initials = getInitials(account.name)
function chooseTheme(value: string) {
setTheme(value)
setMessage(`Theme set to ${THEMES.find((entry) => entry.value === value)?.label ?? value}.`)
}
async function signOut() {
if (pending) return
setRefusal(null)
setMessage("Signing out…")
setPending(true)
let result: Result<{ signedOut: true }>
try {
result = await onSignOut()
} catch {
result = { ok: false, error: { code: "network", message: "Sign out did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (result.ok) {
setMessage(`Signed out of ${workspace}.`)
} else {
// Never a stale "Signed out" beside the refusal.
setMessage("")
setRefusal(result.error.message)
}
}
return (
<div className="flex items-center gap-3">
{refusal ? (
<Callout variant="danger" role="alert" title="Could not sign you out" className="py-1 text-xs">
{refusal}
</Callout>
) : null}
{/* Always mounted, so each change is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="sr-only text-xs text-muted-foreground sm:not-sr-only">
{message}
</p>
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
aria-label={`Account menu for ${account.name}`}
aria-busy={pending || undefined}
/>
}
>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6} className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5 text-sm font-medium text-foreground">
{account.name}
<span className="truncate text-xs font-normal text-muted-foreground">{account.email}</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem render={<Link href="/profile" />}>
<UserIcon aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/billing/plans" />}>
<CreditCardIcon aria-hidden="true" />
Billing
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings" />}>
<SettingsIcon aria-hidden="true" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={current} onValueChange={(value) => chooseTheme(String(value))}>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
{THEMES.map((entry) => (
<DropdownMenuRadioItem key={entry.value} value={entry.value}>
<entry.icon aria-hidden="true" />
{entry.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={pending} onClick={() => void signOut()}>
<LogOutIcon aria-hidden="true" />
{pending ? "Signing out…" : "Sign out"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}/**
* What `user-menus-02` reads: who is signed in — the reader, from
* `team-projects`: the first member, the workspace's owner — with their face
* and email; the day, for the page the header sits over; and the one thing
* the menu changes on the server, signing out. The theme is the reader's own
* choice and never reaches the server.
*
* `signOut` is `team-projects`' own: it goes through the kit's
* `mockAuthAdapter` — a stub — and always answers with a `Result`, an adapter
* that throws coming back as a refusal. Swap the adapter for your auth
* backend and the menu signs out for real.
*
* Selectors only, and the one server action, all run on the server.
*/
import { greeting, reader, signOut } from "@/lib/dashboards/projects/team"
import type { Result } from "@/lib/sample-data"
export { signOut }
export type { Result }
/** The workspace the header belongs to: the Projects dashboard's brand. */
export const WORKSPACE = "Northwind Work"
export type Account = { name: string; email: string; avatarUrl: string }
/** The signed-in member; null when nobody is. */
export function account(): Account | null {
const who = reader()
return who ? { name: who.name, email: who.email, avatarUrl: who.avatarUrl } : null
}
/** The day the page is on, in UTC: "Friday, September 4". */
export function today(): string {
return greeting().today
}$
npx shadcn@latest add @vibra/user-menus-02Workspace switcher that holds the account
user-menus-03The open workspace at the top of a sidebar, with its plan, seats and the signed-in face, opening every active workspace to switch into, then the account and Sign out.
import * as React from "react"
import Link from "next/link"
import { ChartLineIcon, CreditCardIcon, LayoutDashboardIcon, UsersIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { WorkspaceAccountMenu } from "./user-menus-03.client"
import { account, openWorkspace, signOut, workspaces } from "./user-menus-03.data"
/** The sidebar the switcher heads: the SaaS dashboard's pages, Overview the current one. */
const NAV: { title: string; href: string; icon: LucideIcon; current?: true }[] = [
{ title: "Overview", href: "/saas", icon: LayoutDashboardIcon, current: true },
{ title: "Revenue", href: "/saas/revenue", icon: ChartLineIcon },
{ title: "Customers", href: "/saas/customers", icon: UsersIcon },
{ title: "Subscriptions", href: "/saas/subscriptions", icon: CreditCardIcon },
]
/**
* A workspace switcher that also holds the account, at the top of a sidebar:
* the open workspace with its plan and seats and the signed-in face on the
* row, and in the menu every active workspace to switch into, then who is
* signed in and the way out. The sidebar is drawn under it so the row sits
* where it will once installed.
*
* A server component: it reads the workspaces and the account through
* `./user-menus-03.data`, and hands the island them with `signOutAction`, a
* server function over the kit's auth adapter.
*/
export function UserMenus03({ defaultOpen = false }: { defaultOpen?: boolean }) {
const titleId = React.useId()
const rows = workspaces()
const who = account()
async function signOutAction() {
"use server"
return signOut()
}
return (
<section
data-section="user-menus-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Workspace and account</h2>
</CardTitle>
<CardDescription className="tabular-nums">
One menu at the top of the sidebar: {rows.length} workspaces to switch between, and the account
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="grid min-h-[30rem] md:grid-cols-[17rem_minmax(0,1fr)]">
<div className="flex flex-col gap-4 bg-sidebar p-2 text-sidebar-foreground md:border-e md:border-border">
{who ? (
<WorkspaceAccountMenu
workspaces={rows}
openId={openWorkspace()}
account={who}
onSignOut={signOutAction}
defaultOpen={defaultOpen}
/>
) : (
// Nobody signed in: the top of the sidebar is the way in.
<Link href="/sign-in" className={cn(buttonVariants({ variant: "outline" }), "w-full")}>
Sign in
</Link>
)}
<nav aria-label="Workspace pages">
<ul className="flex flex-col gap-0.5">
{NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
aria-current={item.current ? "page" : undefined}
className={cn(
"flex h-8 items-center gap-2 rounded-md px-2 text-sm outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring",
item.current && "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
)}
>
<item.icon aria-hidden="true" className="size-4 shrink-0" />
{item.title}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div className="hidden flex-col gap-1 p-6 md:flex">
<p className="type-display text-xl">Overview</p>
<p className="text-sm text-muted-foreground">Traffic, sign-ups and revenue for the workspace open in the sidebar.</p>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default UserMenus03"use client"
import * as React from "react"
import Link from "next/link"
import { ChevronsUpDownIcon, LogOutIcon, UserCogIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Callout } from "@/components/ui/callout"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { WorkspaceMark } from "@/components/ui/workspace-switcher"
import type { Account, Result, WorkspaceRow } from "./user-menus-03.data"
export type WorkspaceAccountMenuProps = {
workspaces: WorkspaceRow[]
/** The workspace open when the band loads; null when the organisation has none active. */
openId: string | null
account: Account
/** The sign-out action: a server function returning a `Result`. */
onSignOut: () => Promise<Result<{ signedOut: true }>>
/** Opens the menu on first render; the preview uses it, an install leaves it off. */
defaultOpen?: boolean
}
const seats = (count: number) => `${count} ${count === 1 ? "seat" : "seats"}`
/**
* The workspace at the top of a sidebar, and a menu that switches it and
* holds the account: the workspaces as one choice — the open one ticked on
* the accent's tint — then who is signed in, their account settings and Sign
* out. Picking a workspace closes the menu, renames the row and says so in
* the status line under it. The kit's DropdownMenu gives it the whole
* keyboard: the arrows move, a letter jumps to the workspace it starts,
* Escape closes it and the focus goes back to the row.
*/
export function WorkspaceAccountMenu({ workspaces, openId, account, onSignOut, defaultOpen = false }: WorkspaceAccountMenuProps) {
const [activeId, setActiveId] = React.useState<string | null>(openId)
const [message, setMessage] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
// True while a sign-out is on its way: the trigger is busy and the item waits.
const [pending, setPending] = React.useState(false)
// With no active workspace the row is the account alone, and the menu holds only it.
const active = workspaces.find((workspace) => workspace.id === activeId) ?? workspaces[0] ?? null
function choose(id: string) {
const next = workspaces.find((workspace) => workspace.id === id)
if (!next || next.id === activeId) return
setActiveId(next.id)
setMessage(`Switched to ${next.name}.`)
}
async function signOut() {
if (pending) return
setRefusal(null)
setMessage("Signing out…")
setPending(true)
let result: Result<{ signedOut: true }>
try {
result = await onSignOut()
} catch {
result = { ok: false, error: { code: "network", message: "Sign out did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (result.ok) {
setMessage(active ? `Signed out of ${active.name}.` : "Signed out.")
} else {
// Never a stale "Signed out" beside the refusal.
setMessage("")
setRefusal(result.error.message)
}
}
return (
<div className="flex flex-col">
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
aria-label={
active
? `${active.name}, signed in as ${account.name}. Switch workspace or account`
: `Signed in as ${account.name}. Account menu`
}
aria-busy={pending || undefined}
className="flex w-full items-center gap-2.5 rounded-md p-2 text-left outline-none hover:bg-sidebar-accent focus-ring data-popup-open:bg-sidebar-accent"
>
{active ? <WorkspaceMark workspace={{ id: active.id, name: active.name }} /> : null}
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="truncate text-sm font-semibold">{active ? active.name : account.name}</span>
<span className="truncate text-xs text-muted-foreground tabular-nums">
{active ? `${active.plan} · ${seats(active.seats)}` : "No active workspace"}
</span>
</span>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{getInitials(account.name)}</AvatarFallback>
</Avatar>
<ChevronsUpDownIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6} className="w-72">
{active ? (
<>
<DropdownMenuRadioGroup value={active.id} onValueChange={(value) => choose(String(value))}>
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuRadioItem
key={workspace.id}
value={workspace.id}
closeOnClick
label={workspace.name}
// The item repaints its children on focus; the mark keeps its own ink.
className="gap-2 data-checked:bg-brand-muted data-checked:font-medium focus:[&_[data-slot=workspace-switcher-mark]]:text-primary-foreground"
>
<WorkspaceMark workspace={{ id: workspace.id, name: workspace.name }} size="sm" />
<span className="truncate">{workspace.name}</span>
<span className="ms-auto shrink-0 text-xs font-normal text-muted-foreground">{workspace.plan}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2.5 py-1.5 text-foreground">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{getInitials(account.name)}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-sm font-medium">{account.name}</span>
<span className="truncate text-xs font-normal text-muted-foreground">{account.email}</span>
</span>
</DropdownMenuLabel>
<DropdownMenuItem render={<Link href="/settings/profile" />}>
<UserCogIcon aria-hidden="true" />
Account settings
</DropdownMenuItem>
<DropdownMenuItem disabled={pending} onClick={() => void signOut()}>
<LogOutIcon aria-hidden="true" />
{pending ? "Signing out…" : "Sign out"}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{refusal ? (
<Callout variant="danger" role="alert" title="Could not sign you out" className="mt-2">
{refusal}
</Callout>
) : null}
{/* Always mounted, so each change is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="px-2 text-xs text-pretty text-muted-foreground [&:not(:empty)]:mt-2">
{message}
</p>
</div>
)
}/**
* What `user-menus-03` reads: the workspaces the organisation runs and the
* one open now, with who is signed in. Northwind keeps one account row per
* workspace in `db.customers` — the rows under the Northwind name — and the
* ones still active are the ones a member can switch into, largest first;
* the one open is Northwind Analytics, the workspace the settings pages
* name. The member is the reader, from `team-projects`: the first member,
* the workspace's owner.
*
* `signOut` is `team-projects`' own: it goes through the kit's
* `mockAuthAdapter` — a stub — and always answers with a `Result`, an adapter
* that throws coming back as a refusal. Swap the adapter for your auth
* backend and the menu signs out for real.
*
* Selectors only, reading `db` inside functions, and the one server action,
* all run on the server.
*/
import { reader, signOut } from "@/lib/dashboards/projects/team"
import { db, type Customer, type Result } from "@/lib/sample-data"
export { signOut }
export type { Result }
/** The workspace the band opens on, as the settings pages name it. */
export const OPEN_WORKSPACE = "Northwind Analytics"
const PLAN_LABELS: Record<Customer["plan"], string> = { free: "Free", starter: "Starter", team: "Team", enterprise: "Enterprise" }
export type WorkspaceRow = { id: string; name: string; plan: string; seats: number }
export type Account = { name: string; email: string; avatarUrl: string }
/** The organisation's active workspaces, largest first; a tie goes to the name. */
export function workspaces(): WorkspaceRow[] {
return db.customers
.all()
.filter((customer) => customer.company.startsWith("Northwind ") && customer.status === "active")
.sort((a, b) => b.seats - a.seats || a.company.localeCompare(b.company))
.map((customer) => ({ id: customer.id, name: customer.company, plan: PLAN_LABELS[customer.plan], seats: customer.seats }))
}
/**
* The workspace open now: Northwind Analytics, or the largest when it is not
* active; null when the organisation has no active workspace.
*/
export function openWorkspace(): string | null {
const rows = workspaces()
return (rows.find((row) => row.name === OPEN_WORKSPACE) ?? rows[0])?.id ?? null
}
/** The signed-in member; null when nobody is. */
export function account(): Account | null {
const who = reader()
return who ? { name: who.name, email: who.email, avatarUrl: who.avatarUrl } : null
}$
npx shadcn@latest add @vibra/user-menus-03Compact menu with a status
user-menus-04A pill with the face, a status dot and the status word, opening the account, Online, Away or Do not disturb, the profile and Sign out, beside the teammates who see it.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { UserCell } from "@/components/ui/user-cell"
import { StatusMenu } from "./user-menus-04.client"
import { account, signOut, teammates } from "./user-menus-04.data"
/**
* A compact account menu that carries a status, beside the people who see
* it: the reader's pill — face, dot and status word — opens their account,
* the three statuses and the way out; beside it their teammates, the most
* recently active first, each with when they were last about.
*
* A server component: it reads the account and the team through
* `./user-menus-04.data`, and hands the island the account and
* `signOutAction`, a server function over the kit's auth adapter.
*/
export function UserMenus04({ defaultOpen = false }: { defaultOpen?: boolean }) {
const titleId = React.useId()
const youId = React.useId()
const teamId = React.useId()
const who = account()
const team = teammates()
async function signOutAction() {
"use server"
return signOut()
}
return (
<section
data-section="user-menus-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Status and account</h2>
</CardTitle>
<CardDescription>A compact menu for how you show to the team, your profile and the way out</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="grid md:grid-cols-2">
<div role="group" aria-labelledby={youId} className="flex min-h-[28rem] flex-col gap-3 p-5 md:p-6">
<div className="flex flex-col gap-1">
<p id={youId} className="type-eyebrow">
You
</p>
<p className="max-w-sm text-sm text-pretty text-muted-foreground">
Your status shows beside your name wherever the team sees you, until you change it or sign out.
</p>
</div>
{who ? (
<StatusMenu account={who} onSignOut={signOutAction} defaultOpen={defaultOpen} />
) : (
// Nobody signed in: there is no status to set, only the way in.
<Link href="/sign-in" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-start")}>
Sign in
</Link>
)}
</div>
<div className="flex flex-col gap-3 border-t border-border p-5 md:border-t-0 md:border-s md:p-6">
{team.length ? (
<>
<p id={teamId} className="type-eyebrow">
Your team, most recently active first
</p>
<ul aria-labelledby={teamId} className="flex flex-col gap-3">
{team.map((person) => (
<li key={person.name}>
<UserCell
name={person.name}
src={person.avatarUrl}
description={<time dateTime={person.iso}>{person.lastActive}</time>}
/>
</li>
))}
</ul>
</>
) : (
// No heading over an empty list: the column says why it is empty instead.
<EmptyState
size="sm"
title="No teammates yet"
description="The people you share the workspace with show here, the most recently active first."
/>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default UserMenus04"use client"
import * as React from "react"
import Link from "next/link"
import { ChevronDownIcon, LogOutIcon, UserIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Account, Result } from "./user-menus-04.data"
/** The statuses a member can set: the word always shows, the dot only repeats it. */
const STATUSES = [
{ value: "online", label: "Online", hint: "Shown as available", dot: "bg-success" },
{ value: "away", label: "Away", hint: "Shown as away from the desk", dot: "bg-warning" },
{ value: "busy", label: "Do not disturb", hint: "Notifications wait until you are back", dot: "bg-danger" },
] as const
/** Not a choice: what a signed-out member shows as, and the pill after Sign out. */
const OFFLINE = { value: "offline", label: "Offline", hint: "Signed out", dot: "bg-muted-foreground" } as const
type Status = (typeof STATUSES)[number]["value"] | typeof OFFLINE.value
export type StatusMenuProps = {
account: Account
/** The sign-out action: a server function returning a `Result`. */
onSignOut: () => Promise<Result<{ signedOut: true }>>
/** Opens the menu on first render; the preview uses it, an install leaves it off. */
defaultOpen?: boolean
}
/**
* A compact account menu that carries a status: a pill with the face, a dot
* and the status word opens who is signed in, the three statuses as one
* choice, the profile and Sign out. A status is picked in place — the menu
* stays open to show the tick move — and the pill, its name and the status
* line under it follow. The kit's DropdownMenu gives it the whole keyboard,
* the focus back on the pill when it closes.
*/
export function StatusMenu({ account, onSignOut, defaultOpen = false }: StatusMenuProps) {
const [status, setStatus] = React.useState<Status>("online")
const [message, setMessage] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
// True while a sign-out is on its way: the trigger is busy and the item waits.
const [pending, setPending] = React.useState(false)
const current = [...STATUSES, OFFLINE].find((entry) => entry.value === status) ?? OFFLINE
function choose(value: string) {
const next = STATUSES.find((entry) => entry.value === value)
if (!next) return
setStatus(next.value)
setMessage(`Your status is now ${next.label}. ${next.hint}.`)
}
async function signOut() {
if (pending) return
setRefusal(null)
setMessage("Signing out…")
setPending(true)
let result: Result<{ signedOut: true }>
try {
result = await onSignOut()
} catch {
result = { ok: false, error: { code: "network", message: "Sign out did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (result.ok) {
setStatus("offline")
setMessage("Signed out. Your team sees you as offline.")
} else {
// Never a stale "Signed out" beside the refusal.
setMessage("")
setRefusal(result.error.message)
}
}
return (
<div className="flex flex-col gap-2">
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
data-status={status}
aria-label={`Account menu for ${account.name}, ${current.label}`}
aria-busy={pending || undefined}
className="h-8 self-start rounded-full ps-1 pe-2.5"
/>
}
>
<span className="relative flex">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{getInitials(account.name)}</AvatarFallback>
</Avatar>
<span aria-hidden="true" className={cn("absolute -end-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-card", current.dot)} />
</span>
{current.label}
<ChevronDownIcon aria-hidden="true" data-icon="inline-end" className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6} className="w-64">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5 text-sm font-medium text-foreground">
{account.name}
<span className="truncate text-xs font-normal text-muted-foreground">{account.email}</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={status} onValueChange={(value) => choose(String(value))}>
<DropdownMenuLabel>Status</DropdownMenuLabel>
{STATUSES.map((entry) => (
<DropdownMenuRadioItem key={entry.value} value={entry.value} label={entry.label} className="items-start">
<span aria-hidden="true" className={cn("mt-1.5 size-2 shrink-0 rounded-full", entry.dot)} />
<span className="flex flex-col">
{entry.label}
<span className="text-xs text-muted-foreground">{entry.hint}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/profile" />}>
<UserIcon aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem disabled={pending} onClick={() => void signOut()}>
<LogOutIcon aria-hidden="true" />
{pending ? "Signing out…" : "Sign out"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{refusal ? (
<Callout variant="danger" role="alert" title="Could not sign you out">
{refusal}
</Callout>
) : null}
{/* Always mounted, so each change is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="text-xs text-pretty text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `user-menus-04` reads: who is signed in — the reader, from
* `team-projects`: the first member, the workspace's owner — and the teammates they share the workspace with, the
* most recently active first, each with when they were last active, said
* from `REFERENCE_DATE`. The members are `db.members` rows. A member's status
* — online, away, do not disturb — is theirs to set and the store keeps
* none, so the reader's starts at online, the one thing reading the page
* proves.
*
* `signOut` is `team-projects`' own: it goes through the kit's
* `mockAuthAdapter` — a stub — and always answers with a `Result`, an adapter
* that throws coming back as a refusal. Swap the adapter for your auth
* backend and the menu signs out for real.
*
* Selectors only, reading `db` inside functions, and the one server action,
* all run on the server.
*/
import { avatarFor } from "@/lib/avatars"
import { reader, signOut } from "@/lib/dashboards/projects/team"
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
export { signOut }
export type { Result }
/** How many teammates the band lists. */
export const TEAM_COUNT = 5
const MINUTE_MS = 60_000
const HOUR_MS = 3_600_000
const DAY_MS = 86_400_000
export type Account = { name: string; email: string; avatarUrl: string }
export type Teammate = { name: string; avatarUrl: string; lastActive: string; iso: string }
/** "Active 38 minutes ago", "Active 5 hours ago", "Active yesterday", "Active 4 days ago". */
export function lastActive(at: Date): string {
const ms = REFERENCE_DATE.getTime() - at.getTime()
const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`
if (ms < HOUR_MS) return `Active ${plural(Math.max(1, Math.floor(ms / MINUTE_MS)), "minute")} ago`
if (ms < DAY_MS) return `Active ${plural(Math.floor(ms / HOUR_MS), "hour")} ago`
const days = Math.floor(ms / DAY_MS)
return days === 1 ? "Active yesterday" : `Active ${days} days ago`
}
/** The signed-in member; null when nobody is. */
export function account(): Account | null {
const who = reader()
return who ? { name: who.name, email: who.email, avatarUrl: who.avatarUrl } : null
}
/** The reader's active teammates, the most recently active first. */
export function teammates(count = TEAM_COUNT): Teammate[] {
const me = reader()
return db.members
.all()
.filter((member) => member.status === "active" && member.id !== me?.id)
.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime())
.slice(0, count)
.map((member) => ({
name: member.name,
avatarUrl: avatarFor(member.name),
lastActive: lastActive(member.lastActiveAt),
iso: member.lastActiveAt.toISOString(),
}))
}$
npx shadcn@latest add @vibra/user-menus-04