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:
parent
0cf9e02c4e
commit
6ac1abfa86
@ -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
131
src/components/account-modal.tsx
Executable 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
86
src/components/command-menu.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -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
80
src/components/nav-projects.tsx
Executable 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
85
src/components/notifications-popover.tsx
Executable file
85
src/components/notifications-popover.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
19
src/components/theme-provider.tsx
Executable file
19
src/components/theme-provider.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user