Skip to content

Commit 89b78ba

Browse files
feat(web): settings page and theme (#65)
Co-authored-by: Chenxin Yan <yanchenxin2004@gmail.com>
1 parent feb0ec4 commit 89b78ba

13 files changed

Lines changed: 320 additions & 14 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"clsx": "^2.1.1",
3131
"convex": "^1.28.0",
3232
"date-fns": "^4.1.0",
33-
"lucide-react": "^0.548.0",
33+
"lucide-react": "^0.552.0",
3434
"next": "^15.5.6",
3535
"next-themes": "^0.4.6",
3636
"pdfjs-dist": "^5.4.296",

apps/web/public/ui-dark.png

364 Bytes
Loading

apps/web/public/ui-light.png

370 Bytes
Loading

apps/web/public/ui-system.png

414 Bytes
Loading

apps/web/src/app/dashboard/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { auth, currentUser } from "@clerk/nextjs/server";
22
import { cookies } from "next/headers";
33
import { AppSidebar } from "@/app/dashboard/components/sidebar/app-sidebar";
44
import { AppConfigProvider } from "@/components/AppConfigProvider";
5+
import { SettingsDialog } from "@/components/SettingsDialog";
56
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
67

78
export default async function Layout({
@@ -40,6 +41,7 @@ export default async function Layout({
4041
<main className="flex-1 p-6 space-y-6">{children}</main>
4142
</SidebarInset>
4243
</SidebarProvider>
44+
<SettingsDialog />
4345
</AppConfigProvider>
4446
);
4547
}

apps/web/src/app/layout.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
33
import { Geist, Geist_Mono } from "next/font/google";
44
import "./globals.css";
55
import ConvexClientProvider from "@/components/ConvexClientProvider";
6+
import { ThemeProvider } from "@/components/ThemeProvider";
67
import { Toaster } from "@/components/ui/sonner";
78

89
const geistSans = Geist({
@@ -26,14 +27,21 @@ export default function RootLayout({
2627
children: React.ReactNode;
2728
}>) {
2829
return (
29-
<html lang="en">
30+
<html lang="en" suppressHydrationWarning>
3031
<body
3132
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3233
>
33-
<ClerkProvider>
34-
<ConvexClientProvider>{children}</ConvexClientProvider>
35-
</ClerkProvider>
36-
<Toaster position="top-center" richColors />
34+
<ThemeProvider
35+
attribute="class"
36+
defaultTheme="system"
37+
enableSystem
38+
disableTransitionOnChange
39+
>
40+
<ClerkProvider>
41+
<ConvexClientProvider>{children}</ConvexClientProvider>
42+
</ClerkProvider>
43+
<Toaster position="top-center" richColors />
44+
</ThemeProvider>
3745
</body>
3846
</html>
3947
);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"use client";
2+
3+
import { Paintbrush } from "lucide-react";
4+
import { useRouter } from "next/navigation";
5+
import * as React from "react";
6+
import ThemeSelector from "@/components/ThemeSelector";
7+
import {
8+
Breadcrumb,
9+
BreadcrumbItem,
10+
BreadcrumbLink,
11+
BreadcrumbList,
12+
BreadcrumbPage,
13+
BreadcrumbSeparator,
14+
} from "@/components/ui/breadcrumb";
15+
import {
16+
Dialog,
17+
DialogContent,
18+
DialogDescription,
19+
DialogTitle,
20+
} from "@/components/ui/dialog";
21+
import {
22+
Sidebar,
23+
SidebarContent,
24+
SidebarGroup,
25+
SidebarGroupContent,
26+
SidebarHeader,
27+
SidebarMenu,
28+
SidebarMenuButton,
29+
SidebarMenuItem,
30+
SidebarProvider,
31+
} from "@/components/ui/sidebar";
32+
33+
const settingsNav = [{ name: "Appearance", icon: Paintbrush }];
34+
35+
export function SettingsDialog() {
36+
const [open, setOpen] = React.useState(false);
37+
const [activeSection, setActiveSection] = React.useState("Appearance");
38+
const router = useRouter();
39+
40+
React.useEffect(() => {
41+
const handleHashChange = () => {
42+
setOpen(window.location.hash === "#settings");
43+
};
44+
45+
handleHashChange();
46+
47+
window.addEventListener("hashchange", handleHashChange);
48+
49+
return () => {
50+
window.removeEventListener("hashchange", handleHashChange);
51+
};
52+
}, []);
53+
54+
const handleOpenChange = (newOpen: boolean) => {
55+
setOpen(newOpen);
56+
if (!newOpen) {
57+
if (window.location.hash === "#settings") {
58+
router.push(window.location.pathname + window.location.search);
59+
}
60+
}
61+
};
62+
63+
return (
64+
<Dialog open={open} onOpenChange={handleOpenChange}>
65+
<DialogContent className="overflow-hidden p-0 md:max-w-[700px] lg:max-w-[800px] max-h-[600px]">
66+
<DialogTitle className="sr-only">Settings</DialogTitle>
67+
<DialogDescription className="sr-only">
68+
Customize your application settings
69+
</DialogDescription>
70+
<SidebarProvider className="items-start">
71+
<Sidebar collapsible="none" className="hidden md:flex border-r">
72+
<SidebarHeader>
73+
<h2 className="text-lg font-semibold px-2">Settings</h2>
74+
</SidebarHeader>
75+
<SidebarContent>
76+
<SidebarGroup>
77+
<SidebarGroupContent>
78+
<SidebarMenu>
79+
{settingsNav.map((item) => (
80+
<SidebarMenuItem key={item.name}>
81+
<SidebarMenuButton
82+
onClick={() => setActiveSection(item.name)}
83+
isActive={item.name === activeSection}
84+
>
85+
<item.icon />
86+
<span>{item.name}</span>
87+
</SidebarMenuButton>
88+
</SidebarMenuItem>
89+
))}
90+
</SidebarMenu>
91+
</SidebarGroupContent>
92+
</SidebarGroup>
93+
</SidebarContent>
94+
</Sidebar>
95+
<main className="flex flex-1 flex-col overflow-hidden">
96+
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-6">
97+
<Breadcrumb>
98+
<BreadcrumbList>
99+
<BreadcrumbItem>
100+
<BreadcrumbLink href="#settings">Settings</BreadcrumbLink>
101+
</BreadcrumbItem>
102+
<BreadcrumbSeparator />
103+
<BreadcrumbItem>
104+
<BreadcrumbPage>{activeSection}</BreadcrumbPage>
105+
</BreadcrumbItem>
106+
</BreadcrumbList>
107+
</Breadcrumb>
108+
</header>
109+
<div className="flex flex-1 flex-col gap-6 overflow-y-auto p-6">
110+
{activeSection === "Appearance" && (
111+
<div>
112+
<h2 className="text-lg font-medium mb-4">Appearance</h2>
113+
<ThemeSelector />
114+
</div>
115+
)}
116+
</div>
117+
</main>
118+
</SidebarProvider>
119+
</DialogContent>
120+
</Dialog>
121+
);
122+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use client";
2+
3+
import { ThemeProvider as NextThemesProvider } from "next-themes";
4+
import type * as React from "react";
5+
6+
export function ThemeProvider({
7+
children,
8+
...props
9+
}: React.ComponentProps<typeof NextThemesProvider>) {
10+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
11+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"use client";
2+
3+
import { CheckIcon, MinusIcon } from "lucide-react";
4+
import Image from "next/image";
5+
import { useTheme } from "next-themes";
6+
import { useId } from "react";
7+
8+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
9+
10+
const items = [
11+
{ value: "light", label: "Light", image: "/ui-light.png" },
12+
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
13+
{ value: "system", label: "System", image: "/ui-system.png" },
14+
];
15+
16+
export default function ThemeSelector() {
17+
const id = useId();
18+
const { theme, setTheme } = useTheme();
19+
20+
return (
21+
<fieldset className="space-y-4">
22+
<legend className="text-sm leading-none font-medium text-foreground">
23+
Choose a theme
24+
</legend>
25+
<RadioGroup className="flex gap-3" value={theme} onValueChange={setTheme}>
26+
{items.map((item) => (
27+
<label key={`${id}-${item.value}`} htmlFor={`${id}-${item.value}`}>
28+
<RadioGroupItem
29+
id={`${id}-${item.value}`}
30+
value={item.value}
31+
className="peer sr-only after:absolute after:inset-0"
32+
/>
33+
<Image
34+
src={item.image}
35+
alt={item.label}
36+
width={120}
37+
height={95}
38+
className="relative cursor-pointer overflow-hidden rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-disabled:opacity-50 peer-data-[state=checked]:border-ring peer-data-[state=checked]:bg-accent"
39+
/>
40+
<span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
41+
<CheckIcon
42+
size={16}
43+
className="group-peer-data-[state=unchecked]:hidden"
44+
aria-hidden="true"
45+
/>
46+
<MinusIcon
47+
size={16}
48+
className="group-peer-data-[state=checked]:hidden"
49+
aria-hidden="true"
50+
/>
51+
<span className="text-xs font-medium">{item.label}</span>
52+
</span>
53+
</label>
54+
))}
55+
</RadioGroup>
56+
</fieldset>
57+
);
58+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as React from "react"
2+
import { Slot } from "@radix-ui/react-slot"
3+
import { ChevronRight, MoreHorizontal } from "lucide-react"
4+
5+
import { cn } from "@/lib/utils"
6+
7+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
9+
}
10+
11+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12+
return (
13+
<ol
14+
data-slot="breadcrumb-list"
15+
className={cn(
16+
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
17+
className
18+
)}
19+
{...props}
20+
/>
21+
)
22+
}
23+
24+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25+
return (
26+
<li
27+
data-slot="breadcrumb-item"
28+
className={cn("inline-flex items-center gap-1.5", className)}
29+
{...props}
30+
/>
31+
)
32+
}
33+
34+
function BreadcrumbLink({
35+
asChild,
36+
className,
37+
...props
38+
}: React.ComponentProps<"a"> & {
39+
asChild?: boolean
40+
}) {
41+
const Comp = asChild ? Slot : "a"
42+
43+
return (
44+
<Comp
45+
data-slot="breadcrumb-link"
46+
className={cn("hover:text-foreground transition-colors", className)}
47+
{...props}
48+
/>
49+
)
50+
}
51+
52+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53+
return (
54+
<span
55+
data-slot="breadcrumb-page"
56+
role="link"
57+
aria-disabled="true"
58+
aria-current="page"
59+
className={cn("text-foreground font-normal", className)}
60+
{...props}
61+
/>
62+
)
63+
}
64+
65+
function BreadcrumbSeparator({
66+
children,
67+
className,
68+
...props
69+
}: React.ComponentProps<"li">) {
70+
return (
71+
<li
72+
data-slot="breadcrumb-separator"
73+
role="presentation"
74+
aria-hidden="true"
75+
className={cn("[&>svg]:size-3.5", className)}
76+
{...props}
77+
>
78+
{children ?? <ChevronRight />}
79+
</li>
80+
)
81+
}
82+
83+
function BreadcrumbEllipsis({
84+
className,
85+
...props
86+
}: React.ComponentProps<"span">) {
87+
return (
88+
<span
89+
data-slot="breadcrumb-ellipsis"
90+
role="presentation"
91+
aria-hidden="true"
92+
className={cn("flex size-9 items-center justify-center", className)}
93+
{...props}
94+
>
95+
<MoreHorizontal className="size-4" />
96+
<span className="sr-only">More</span>
97+
</span>
98+
)
99+
}
100+
101+
export {
102+
Breadcrumb,
103+
BreadcrumbList,
104+
BreadcrumbItem,
105+
BreadcrumbLink,
106+
BreadcrumbPage,
107+
BreadcrumbSeparator,
108+
BreadcrumbEllipsis,
109+
}

0 commit comments

Comments
 (0)