
A user directory with a member list beside a detail pane for profile, sessions, and security settings.
@esuiss
bases/radix/components/blocks/users-detail.tsx
"use client"
import * as React from "react"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import {
SplitView,
SplitViewDetail,
SplitViewItem,
SplitViewList,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type NavItem = {
value: string
label: string
icon: React.ReactNode
badge?: React.ReactNode
group?: string
}
function NavSidebar({
items,
value: valueProp,
defaultValue,
onValueChange,
header,
footer,
}: {
items: NavItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
header?: React.ReactNode
footer?: React.ReactNode
}) {
const { isMobile, setOpenMobile } = useSidebar()
const [uncontrolledValue, setUncontrolledValue] = React.useState(
defaultValue ?? items[0]?.value
)
const value = valueProp ?? uncontrolledValue
const groups = [...new Set(items.map((item) => item.group ?? ""))]
return (
<Sidebar collapsible="icon">
{header && (
<SidebarHeader className="group-data-[collapsible=icon]:hidden">
{header}
</SidebarHeader>
)}
<SidebarContent>
{groups.map((group) => (
<SidebarGroup key={group}>
{group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{items
.filter((item) => (item.group ?? "") === group)
.map((item) => (
<SidebarMenuItem key={item.value}>
<SidebarMenuButton
isActive={item.value === value}
tooltip={item.label}
aria-current={item.value === value ? "page" : undefined}
onClick={() => {
if (valueProp === undefined) {
setUncontrolledValue(item.value)
}
onValueChange?.(item.value)
if (isMobile) setOpenMobile(false)
}}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
{item.badge !== undefined && (
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
{footer && (
<SidebarFooter className="group-data-[collapsible=icon]:hidden">
{footer}
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
)
}
const nav: NavItem[] = [
{
value: "users",
label: "Users",
icon: (
<IconPlaceholder
lucide="UsersIcon"
tabler="IconUsers"
hugeicons="UserGroupIcon"
phosphor="UsersIcon"
remixicon="RiGroupLine"
/>
),
},
{
value: "roles",
label: "Roles",
icon: (
<IconPlaceholder
lucide="ShieldIcon"
tabler="IconShield"
hugeicons="Shield01Icon"
phosphor="ShieldIcon"
remixicon="RiShieldLine"
/>
),
},
{
value: "log",
label: "Activity",
icon: (
<IconPlaceholder
lucide="ChartColumnIcon"
tabler="IconChartBar"
hugeicons="ChartColumnIcon"
phosphor="ChartBarIcon"
remixicon="RiBarChartLine"
/>
),
},
{
value: "settings",
label: "Settings",
icon: (
<IconPlaceholder
lucide="SettingsIcon"
tabler="IconSettings"
hugeicons="Settings01Icon"
phosphor="GearIcon"
remixicon="RiSettingsLine"
/>
),
},
]
const roles = ["Admin", "Editor", "Support", "Viewer"]
const users = [
{
name: "Jamie Rivera",
email: "jamie@example.com",
role: "Admin",
status: "Active",
hue: 250,
},
{
name: "Morgan Lee",
email: "morgan@example.com",
role: "Editor",
status: "Active",
hue: 30,
},
{
name: "Riley Chen",
email: "riley@example.com",
role: "Support",
status: "Active",
hue: 150,
},
{
name: "Taylor Kim",
email: "taylor@example.com",
role: "Viewer",
status: "Suspended",
hue: 200,
},
{
name: "Avery Brooks",
email: "avery@example.com",
role: "Editor",
status: "Invited",
hue: 330,
},
{
name: "Jordan Park",
email: "jordan@example.com",
role: "Support",
status: "Active",
hue: 100,
},
]
const initialSessions = [
{
device: "Book Pro · Browser",
place: "San Francisco, US",
time: "Now",
icon: (
<IconPlaceholder
lucide="MonitorIcon"
tabler="IconDeviceDesktop"
hugeicons="ComputerIcon"
phosphor="DesktopIcon"
remixicon="RiComputerLine"
className="size-4"
/>
),
},
{
device: "Phone Pro · App",
place: "San Francisco, US",
time: "2 hr ago",
icon: (
<IconPlaceholder
lucide="SmartphoneIcon"
tabler="IconDeviceMobile"
hugeicons="SmartPhone01Icon"
phosphor="DeviceMobileIcon"
remixicon="RiSmartphoneLine"
className="size-4"
/>
),
},
{
device: "Desktop · Browser",
place: "Seattle, US",
time: "3 days ago",
icon: (
<IconPlaceholder
lucide="GlobeIcon"
tabler="IconWorld"
hugeicons="Globe02Icon"
phosphor="GlobeIcon"
remixicon="RiGlobalLine"
className="size-4"
/>
),
},
]
function UserAvatar({
user,
className,
}: {
user: (typeof users)[number]
className?: string
}) {
return (
<Avatar className={className}>
<AvatarFallback
style={{
background: `linear-gradient(160deg, oklch(0.78 0.1 ${user.hue}), oklch(0.6 0.14 ${user.hue + 30}))`,
}}
className="text-white"
>
{user.name
.split(" ")
.map((part) => part[0])
.join("")}
</AvatarFallback>
</Avatar>
)
}
function SecurityToggle({
label,
initial,
}: {
label: string
initial: boolean
}) {
const [on, setOn] = React.useState(initial)
const id = React.useId()
return (
<Item size="sm" role="listitem">
<ItemContent>
<ItemTitle className="font-normal">
<label htmlFor={id}>{label}</label>
</ItemTitle>
</ItemContent>
<ItemActions>
<Switch id={id} checked={on} onCheckedChange={setOn} />
</ItemActions>
</Item>
)
}
export function UsersDetail() {
const [selected, setSelected] = React.useState(0)
const [tab, setTab] = React.useState("Profile")
const [overrides, setOverrides] = React.useState<Record<number, string>>({})
const [sessionsByUser, setSessionsByUser] = React.useState<
Record<number, typeof initialSessions>
>({})
const user = users[selected]
if (!user) return null
const sessions = sessionsByUser[selected] ?? initialSessions
const setSessions = (
update: (current: typeof initialSessions) => typeof initialSessions
) =>
setSessionsByUser((current) => ({
...current,
[selected]: update(current[selected] ?? initialSessions),
}))
const role = overrides[selected] ?? user.role
return (
<SidebarProvider>
<NavSidebar
items={nav}
defaultValue="users"
header={
<p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
<span className="font-bold">suiss</span> Admin
</p>
}
/>
<SidebarInset>
<div className="flex items-center px-5 pt-4 md:hidden">
<SidebarTrigger />
</div>
<SplitView
defaultColumn="list"
className="flex-1 rounded-none border-0 max-md:p-4 md:min-h-150"
>
<SplitViewList
title="Users"
className="gap-0.5 md:px-4 md:pt-5 [&>h2]:md:not-sr-only [&>h2]:md:px-1 [&>h2]:md:pb-2 [&>h2]:md:text-sm [&>h2]:md:font-semibold [&>h2]:md:text-muted-foreground"
>
{users.map((item, index) => (
<SplitViewItem
key={item.email}
isActive={index === selected}
onClick={() => {
setSelected(index)
setTab("Profile")
}}
className="group/user py-1.5 md:data-active:bg-primary md:data-active:text-primary-foreground"
>
<span className="flex items-center gap-2.5">
<UserAvatar user={item} className="size-7 *:text-xs" />
<span className="flex flex-col">
<span className="text-sm">{item.name}</span>
<span className="text-xs text-muted-foreground md:group-data-active/user:text-primary-foreground">
{overrides[index] ?? item.role}
</span>
</span>
</span>
</SplitViewItem>
))}
</SplitViewList>
<SplitViewDetail title={user.name} className="md:p-6 [&>h2]:sr-only">
<div
key={selected}
className="flex flex-col gap-5 transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
>
<div className="flex items-center gap-3.5">
<UserAvatar user={user} className="size-16 *:text-2xl" />
<div className="flex flex-col gap-0.5">
<p className="text-2xl font-bold">{user.name}</p>
<p className="text-sm text-muted-foreground">
{user.email} · {role}
</p>
</div>
</div>
<ToggleGroup
spacing={0}
type="single"
value={tab}
onValueChange={(value: string) => {
if (value) setTab(value)
}}
aria-label="Section"
>
{["Profile", "Sessions", "Security"].map((item) => (
<ToggleGroupItem
key={item}
value={item}
size="sm"
className="px-4"
>
{item}
</ToggleGroupItem>
))}
</ToggleGroup>
<div
key={tab}
className="flex flex-col gap-3 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
>
{tab === "Profile" && (
<ItemGroup variant="inset">
{[
["Full name", user.name],
["Email", user.email],
["Department", "Product"],
["Joined", "March 12, 2024"],
].map(([label, value]) => (
<React.Fragment key={label}>
<Item size="sm" role="listitem">
<ItemContent>
<ItemTitle className="font-normal">
{label}
</ItemTitle>
</ItemContent>
<ItemActions className="text-muted-foreground">
{value}
</ItemActions>
</Item>
<ItemSeparator />
</React.Fragment>
))}
<Item size="sm" role="listitem">
<ItemContent>
<ItemTitle className="font-normal">Role</ItemTitle>
</ItemContent>
<ItemActions>
<Select
value={role}
onValueChange={(value) =>
setOverrides((current) => ({
...current,
[selected]: value as string,
}))
}
>
<SelectTrigger
size="sm"
aria-label="Role"
className="h-7.5"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{roles.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item size="sm" role="listitem">
<ItemContent>
<ItemTitle className="font-normal">Status</ItemTitle>
</ItemContent>
<ItemActions className="text-muted-foreground">
{user.status}
</ItemActions>
</Item>
</ItemGroup>
)}
{tab === "Sessions" && (
<>
<ItemGroup
variant="inset"
className="**:data-[slot=item-separator]:ms-15"
>
{sessions.map((session, index) => (
<React.Fragment key={session.device}>
{index > 0 && <ItemSeparator />}
<Item size="sm" role="listitem">
<ItemMedia className="size-7.5 rounded-[7px] bg-zinc-500 text-white">
{session.icon}
</ItemMedia>
<ItemContent className="gap-0">
<ItemTitle className="font-normal">
{session.device}
</ItemTitle>
<ItemDescription className="text-xs">
{session.place} · {session.time}
</ItemDescription>
</ItemContent>
<ItemActions>
{index === 0 ? (
<span className="text-sm">This device</span>
) : (
<Button
size="sm"
variant="secondary"
onClick={() =>
setSessions((current) =>
current.filter(
(item) => item.device !== session.device
)
)
}
>
Sign Out
</Button>
)}
</ItemActions>
</Item>
</React.Fragment>
))}
</ItemGroup>
{sessions.length > 1 && (
<Button
size="sm"
variant="destructive"
className="self-start"
onClick={() =>
setSessions((current) => current.slice(0, 1))
}
>
Sign Out All Other Sessions
</Button>
)}
</>
)}
{tab === "Security" && (
<>
<ItemGroup variant="inset" key={selected}>
<SecurityToggle
label="Two-factor authentication"
initial
/>
<ItemSeparator />
<SecurityToggle label="Require passkey" initial={false} />
<ItemSeparator />
<SecurityToggle label="Sign-in alerts" initial />
</ItemGroup>
<Button
size="sm"
variant="destructive"
className="self-start"
>
Send Password Reset Link
</Button>
</>
)}
</div>
</div>
</SplitViewDetail>
</SplitView>
</SidebarInset>
</SidebarProvider>
)
}


























