feat(theme): add dark mode with next-themes and settings toggle
This commit is contained in:
@@ -6,13 +6,22 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { notifySuccess } from "@/lib/toast"
|
import { notifySuccess } from "@/lib/toast"
|
||||||
import type { UserRole } from "@/lib/types"
|
import type { UserRole } from "@/lib/types"
|
||||||
|
import type { ThemePreference } from "@/store/auth"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { Camera } from "lucide-react"
|
import { Camera } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
|
|
||||||
@@ -32,6 +41,14 @@ export default function SettingsPage() {
|
|||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const isRoleVerified = (role: UserRole) => verifiedRoles.includes(role)
|
const isRoleVerified = (role: UserRole) => verifiedRoles.includes(role)
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const setThemePreference = useAuthStore((s) => s.setThemePreference)
|
||||||
|
|
||||||
|
function handleThemeChange(value: string) {
|
||||||
|
const pref = value as ThemePreference
|
||||||
|
setTheme(pref)
|
||||||
|
setThemePreference(pref)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||||
@@ -174,6 +191,27 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">外观设置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>主题模式</Label>
|
||||||
|
<Select value={theme} onValueChange={handleThemeChange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="选择主题" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="light">浅色模式</SelectItem>
|
||||||
|
<SelectItem value="dark">深色模式</SelectItem>
|
||||||
|
<SelectItem value="system">跟随系统</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">通知偏好</CardTitle>
|
<CardTitle className="text-base">通知偏好</CardTitle>
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { GlobalLoginDialog } from "@/components/global-login-dialog"
|
import { GlobalLoginDialog } from "@/components/global-login-dialog"
|
||||||
|
import { ThemeSyncEffect } from "@/components/theme-sync"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
|
|
||||||
@@ -21,11 +23,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<ThemeSyncEffect />
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-center" />
|
<Toaster richColors position="top-center" />
|
||||||
<GlobalLoginDialog />
|
<GlobalLoginDialog />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/auth"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
export function ThemeSyncEffect() {
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const themePreference = useAuthStore((s) => s.themePreference)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTheme(themePreference)
|
||||||
|
}, [isAuthenticated, themePreference, setTheme])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
Generated
+14
@@ -32,6 +32,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.1.6
|
specifier: 16.1.6
|
||||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
radix-ui:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -3308,6 +3311,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
next-themes@0.4.6:
|
||||||
|
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@16.1.6:
|
next@16.1.6:
|
||||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -7525,6 +7534,11 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
|
next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.1.6
|
'@next/env': 16.1.6
|
||||||
|
|||||||
+10
-2
@@ -13,6 +13,8 @@ const defaultNotificationPrefs: NotificationPrefs = {
|
|||||||
system: false,
|
system: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThemePreference = "light" | "dark" | "system"
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
currentRole: UserRole
|
currentRole: UserRole
|
||||||
@@ -20,14 +22,16 @@ interface AuthState {
|
|||||||
verificationStatus: Partial<Record<UserRole, VerificationStatus>>
|
verificationStatus: Partial<Record<UserRole, VerificationStatus>>
|
||||||
verificationReasons: Partial<Record<UserRole, string>>
|
verificationReasons: Partial<Record<UserRole, string>>
|
||||||
notificationPrefs: NotificationPrefs
|
notificationPrefs: NotificationPrefs
|
||||||
|
themePreference: ThemePreference
|
||||||
user: User | null
|
user: User | null
|
||||||
switchRole: (role: UserRole) => void
|
switchRole: (role: UserRole) => void
|
||||||
submitVerification: (role: UserRole) => void
|
submitVerification: (role: UserRole) => void
|
||||||
approveVerification: (role: UserRole) => void
|
approveVerification: (role: UserRole) => void
|
||||||
rejectVerification: (role: UserRole, reason: string) => void
|
rejectVerification: (role: UserRole, reason: string) => void
|
||||||
setNotificationPref: (type: keyof NotificationPrefs, enabled: boolean) => void
|
setNotificationPref: (type: keyof NotificationPrefs, enabled: boolean) => void
|
||||||
|
setThemePreference: (theme: ThemePreference) => void
|
||||||
updateProfile: (patch: { nickname?: string; bio?: string; avatar?: string }) => void
|
updateProfile: (patch: { nickname?: string; bio?: string; avatar?: string }) => void
|
||||||
login: (user: User, verifiedRoles?: UserRole[]) => void
|
login: (user: User, verifiedRoles?: UserRole[], themePreference?: ThemePreference) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
verificationStatus: { consumer: "approved" },
|
verificationStatus: { consumer: "approved" },
|
||||||
verificationReasons: {},
|
verificationReasons: {},
|
||||||
notificationPrefs: defaultNotificationPrefs,
|
notificationPrefs: defaultNotificationPrefs,
|
||||||
|
themePreference: "system",
|
||||||
user: null,
|
user: null,
|
||||||
switchRole: (role) => {
|
switchRole: (role) => {
|
||||||
const { verifiedRoles } = get()
|
const { verifiedRoles } = get()
|
||||||
@@ -106,6 +111,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
[type]: enabled,
|
[type]: enabled,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
setThemePreference: (theme) => set({ themePreference: theme }),
|
||||||
updateProfile: (patch) =>
|
updateProfile: (patch) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.user) return state
|
if (!state.user) return state
|
||||||
@@ -119,7 +125,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
login: (user, verifiedRoles = ["consumer"]) =>
|
login: (user, verifiedRoles = ["consumer"], themePreference) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user,
|
user,
|
||||||
@@ -134,6 +140,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
verificationReasons: {},
|
verificationReasons: {},
|
||||||
notificationPrefs: state.notificationPrefs,
|
notificationPrefs: state.notificationPrefs,
|
||||||
|
themePreference: themePreference ?? state.themePreference,
|
||||||
})),
|
})),
|
||||||
logout: () =>
|
logout: () =>
|
||||||
set({
|
set({
|
||||||
@@ -143,6 +150,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
verificationStatus: { consumer: "approved" },
|
verificationStatus: { consumer: "approved" },
|
||||||
verificationReasons: {},
|
verificationReasons: {},
|
||||||
notificationPrefs: defaultNotificationPrefs,
|
notificationPrefs: defaultNotificationPrefs,
|
||||||
|
themePreference: "system",
|
||||||
user: null,
|
user: null,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user