feat(ui): add toolbar features and account modal

Theme toggle, notifications popover, command palette
(Cmd+K), user account dropdown with settings modal,
subtle sidebar active states, and nav transition
animation.
This commit is contained in:
Nicholai Vogel 2026-01-24 13:03:13 -07:00
parent 0cf9e02c4e
commit 6ac1abfa86
8 changed files with 414 additions and 7 deletions

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Sora, IBM_Plex_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const sora = Sora({
@ -24,9 +25,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={`${sora.variable} ${ibmPlexMono.variable} font-sans antialiased`}>
{children}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);

131
src/components/account-modal.tsx Executable file
View File

@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import { IconCamera } from "@tabler/icons-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
export function AccountModal({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [name, setName] = React.useState("Martine Vogel")
const [email, setEmail] = React.useState("martine@compass.io")
const [currentPassword, setCurrentPassword] = React.useState("")
const [newPassword, setNewPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Account Settings</DialogTitle>
<DialogDescription>
Manage your profile and security settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex items-center gap-4">
<div className="relative">
<Avatar className="size-16">
<AvatarImage src="/avatars/martine.jpg" alt={name} />
<AvatarFallback>MV</AvatarFallback>
</Avatar>
<button className="bg-primary text-primary-foreground absolute -right-1 -bottom-1 flex size-6 items-center justify-center rounded-full">
<IconCamera className="size-3.5" />
</button>
</div>
<div>
<p className="font-medium">{name}</p>
<p className="text-muted-foreground text-sm">{email}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<h4 className="text-sm font-medium">Profile</h4>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<Separator />
<div className="space-y-4">
<h4 className="text-sm font-medium">Change Password</h4>
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

86
src/components/command-menu.tsx Executable file
View File

@ -0,0 +1,86 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import {
IconDashboard,
IconFolder,
IconFiles,
IconCalendarStats,
IconSun,
IconSearch,
} from "@tabler/icons-react"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
export function CommandMenu({
open,
setOpen,
}: {
open: boolean
setOpen: (open: boolean) => void
}) {
const router = useRouter()
const { theme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(!open)
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [open, setOpen])
function runCommand(cmd: () => void) {
setOpen(false)
cmd()
}
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => runCommand(() => router.push("/dashboard"))}>
<IconDashboard />
Dashboard
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/dashboard/projects"))}>
<IconFolder />
Projects
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/dashboard/files"))}>
<IconFiles />
Files
</CommandItem>
<CommandItem onSelect={() => runCommand(() => router.push("/dashboard/projects/demo-project-1/schedule"))}>
<IconCalendarStats />
Schedule
</CommandItem>
</CommandGroup>
<CommandGroup heading="Actions">
<CommandItem onSelect={() => runCommand(() => setTheme(theme === "dark" ? "light" : "dark"))}>
<IconSun />
Toggle theme
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setOpen(true))}>
<IconSearch />
Search files
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
)
}

View File

@ -54,7 +54,6 @@ export function NavFiles() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
@ -66,7 +65,7 @@ export function NavFiles() {
className={cn(
activeView === item.view &&
pathname?.startsWith("/dashboard/files") &&
"bg-sidebar-accent text-sidebar-accent-foreground"
"bg-sidebar-foreground/10 font-medium"
)}
>
<Link

80
src/components/nav-projects.tsx Executable file
View File

@ -0,0 +1,80 @@
"use client"
import { IconArrowLeft, IconFolder } from "@tabler/icons-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { cn } from "@/lib/utils"
export function NavProjects({
projects,
}: {
projects: { id: string; name: string }[]
}) {
const pathname = usePathname()
const activeId = pathname?.match(
/^\/dashboard\/projects\/([^/]+)/
)?.[1]
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Back to Dashboard">
<Link href="/dashboard">
<IconArrowLeft />
<span>Back</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{projects.length === 0 ? (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<IconFolder />
<span className="text-muted-foreground">
No projects
</span>
</SidebarMenuButton>
</SidebarMenuItem>
) : (
projects.map((project) => (
<SidebarMenuItem key={project.id}>
<SidebarMenuButton
asChild
tooltip={project.name}
className={cn(
activeId === project.id &&
"bg-sidebar-foreground/10 font-medium"
)}
>
<Link href={`/dashboard/projects/${project.id}`}>
<IconFolder />
<span className="truncate">
{project.name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)
}

View File

@ -1,5 +1,6 @@
"use client"
import * as React from "react"
import {
IconCreditCard,
IconDotsVertical,
@ -28,6 +29,7 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { AccountModal } from "@/components/account-modal"
export function NavUser({
user,
@ -39,6 +41,7 @@ export function NavUser({
}
}) {
const { isMobile } = useSidebar()
const [accountOpen, setAccountOpen] = React.useState(false)
return (
<SidebarMenu>
@ -51,7 +54,7 @@ export function NavUser({
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">MV</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
@ -72,7 +75,7 @@ export function NavUser({
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">MV</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
@ -84,7 +87,7 @@ export function NavUser({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => setAccountOpen(true)}>
<IconUserCircle />
Account
</DropdownMenuItem>
@ -105,6 +108,7 @@ export function NavUser({
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} />
</SidebarMenu>
)
}

View File

@ -0,0 +1,85 @@
"use client"
import {
IconBell,
IconMessageCircle,
IconAlertCircle,
IconClipboardCheck,
IconClock,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const notifications = [
{
icon: IconClipboardCheck,
title: "Task assigned",
description: "You've been assigned to \"Update homepage layout\"",
time: "2m ago",
},
{
icon: IconMessageCircle,
title: "New comment",
description: "Sarah left a comment on the brand assets file",
time: "15m ago",
},
{
icon: IconAlertCircle,
title: "Deadline approaching",
description: "\"Q1 Report\" is due tomorrow",
time: "1h ago",
},
{
icon: IconClock,
title: "Status changed",
description: "\"API Integration\" moved to In Review",
time: "3h ago",
},
]
export function NotificationsPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative size-8">
<IconBell className="size-4" />
<span className="bg-destructive absolute top-1 right-1 size-1.5 rounded-full" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="border-b px-4 py-3">
<p className="text-sm font-medium">Notifications</p>
</div>
<div className="max-h-72 overflow-y-auto">
{notifications.map((item) => (
<div
key={item.title}
className="hover:bg-muted/50 flex gap-3 border-b px-4 py-3 last:border-0"
>
<item.icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-muted-foreground truncate text-xs">
{item.description}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{item.time}
</p>
</div>
</div>
))}
</div>
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs">
Mark all as read
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,19 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
>
{children}
</NextThemesProvider>
)
}