diff --git a/messages/zh.json b/messages/zh.json index a1ce44a..8329f42 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -173,6 +173,34 @@ "Notification": { "title": "通知设置", "description": "管理您的通知设置" + }, + "Theme": { + "title": "主题设置", + "description": "自定义浅色和深色模式的主题颜色", + "light": "浅色模式", + "dark": "深色模式", + "system": "系统", + "Colors": { + "background": "背景色", + "foreground": "前景色", + "muted": "柔和色", + "muted-foreground": "柔和前景色", + "popover": "弹出层背景色", + "popover-foreground": "弹出层前景色", + "card": "卡片背景色", + "card-foreground": "卡片前景色", + "border": "边框色", + "input": "输入框色", + "primary": "主色", + "primary-foreground": "主色前景色", + "secondary": "次色", + "secondary-foreground": "次色前景色", + "accent": "强调色", + "accent-foreground": "强调色前景色", + "destructive": "危险色", + "destructive-foreground": "危险色前景色", + "ring": "环形色" + } } } } diff --git a/package.json b/package.json index 40dda25..d735ead 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,14 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.5", "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-popover": "^1.1.5", + "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-portal": "^1.1.3", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-select": "^2.1.5", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04b64c3..17e1f92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: specifier: ^2.1.1 version: 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-popover': - specifier: ^1.1.5 + specifier: ^1.1.6 version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-portal': specifier: ^1.1.3 @@ -71,6 +71,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-tabs': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-toggle': specifier: ^1.1.1 version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1507,6 +1510,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.3': + resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toggle-group@1.1.2': resolution: {integrity: sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==} peerDependencies: @@ -4931,6 +4947,22 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-toggle-group@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 diff --git a/src/app/dash/setting/site/theme/page.tsx b/src/app/dash/setting/site/theme/page.tsx new file mode 100644 index 0000000..f005973 --- /dev/null +++ b/src/app/dash/setting/site/theme/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ColorPicker } from "@/components/derive-ui/color-picker"; +import { useTranslations } from "next-intl"; +import type { ThemeColors } from "@/providers/color/types"; +import { defaultTheme, useTheme } from "@/providers/color"; + +export default function ThemePage() { + const t = useTranslations("Private.Setting.Site.Theme"); + const { theme, setTheme } = useTheme(); + + const updateColor = ( + mode: "light" | "dark", + key: keyof ThemeColors, + value: string, + ) => { + if (mode === "light") { + setTheme({ + name: "custom", + ...theme, + light: { ...theme?.light, [key]: value }, + dark: theme?.dark ?? {}, + }); + } else { + setTheme({ + name: "custom", + ...theme, + light: theme?.light ?? {}, + dark: { ...theme?.dark, [key]: value }, + }); + } + }; + + const ColorPickerRow = ({ + colorKey, + value, + onChange, + }: { + mode: "light" | "dark"; + colorKey: keyof ThemeColors; + value: string; + onChange: (value: string) => void; + }) => ( +
+
+ {t(`Variables.${colorKey}`)} + {colorKey} +
+ +
+ ); + + return ( + + + {t("light")} + {t("dark")} + + + + {Object.entries(defaultTheme.light).map(([key, value]) => ( + + updateColor("light", key as keyof ThemeColors, newValue) + } + /> + ))} + + + + {Object.entries(defaultTheme.dark).map(([key, value]) => ( + + updateColor("dark", key as keyof ThemeColors, newValue) + } + /> + ))} + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 582b4b5..5fa0ec4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ import { getLocale, getMessages } from "next-intl/server"; import { NextIntlClientProvider } from "next-intl"; import { pickPublic } from "@/i18n/pick"; import type { Messages } from "global"; +import { ThemeScript } from "@/providers/color/script"; export const metadata: Metadata = { metadataBase: new URL(siteConfig.url), @@ -70,7 +71,9 @@ export default async function RootLayout({ return ( - + + + + + + ); +} + +export function ColorPicker({ + background, + setBackground, + className, +}: { + background: string; + setBackground: (background: string) => void; + className?: string; +}) { + const solids = [ + "#E2E2E2", + "#ff75c3", + "#ffa647", + "#ffe83f", + "#9fff5b", + "#70e2ff", + "#cd93ff", + "#09203f", + ]; + + const gradients = [ + "linear-gradient(to top left,#accbee,#e7f0fd)", + "linear-gradient(to top left,#d5d4d0,#d5d4d0,#eeeeec)", + "linear-gradient(to top left,#000000,#434343)", + "linear-gradient(to top left,#09203f,#537895)", + "linear-gradient(to top left,#AC32E4,#7918F2,#4801FF)", + "linear-gradient(to top left,#f953c6,#b91d73)", + "linear-gradient(to top left,#ee0979,#ff6a00)", + "linear-gradient(to top left,#F00000,#DC281E)", + "linear-gradient(to top left,#00c6ff,#0072ff)", + "linear-gradient(to top left,#4facfe,#00f2fe)", + "linear-gradient(to top left,#0ba360,#3cba92)", + "linear-gradient(to top left,#FDFC47,#24FE41)", + "linear-gradient(to top left,#8a2be2,#0000cd,#228b22,#ccff00)", + "linear-gradient(to top left,#40E0D0,#FF8C00,#FF0080)", + "linear-gradient(to top left,#fcc5e4,#fda34b,#ff7882,#c8699e,#7046aa,#0c1db8,#020f75)", + "linear-gradient(to top left,#ff75c3,#ffa647,#ffe83f,#9fff5b,#70e2ff,#cd93ff)", + ]; + + const images = [ + "url(https://images.unsplash.com/photo-1691200099282-16fd34790ade?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)", + "url(https://images.unsplash.com/photo-1691226099773-b13a89a1d167?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90", + "url(https://images.unsplash.com/photo-1688822863426-8c5f9b257090?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)", + "url(https://images.unsplash.com/photo-1691225850735-6e4e51834cad?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2532&q=90)", + ]; + + const defaultTab = useMemo(() => { + if (background.includes("url")) return "image"; + if (background.includes("gradient")) return "gradient"; + return "solid"; + }, [background]); + + return ( + + + + + + + + + Solid + + + Gradient + + + Image + + + + + {solids.map((s) => ( +
setBackground(s)} + /> + ))} + + + +
+ {gradients.map((s) => ( +
setBackground(s)} + /> + ))} +
+ + + 💡 Get more at{" "} + + Gradient Page + + + + + +
+ {images.map((s) => ( +
setBackground(s)} + /> + ))} +
+ + + 🎁 Get abstract{" "} + + wallpapers + + + + + Change your password here. + + + setBackground(e.currentTarget.value)} + /> + + + ); +} + +const GradientButton = ({ + background, + children, +}: { + background: string; + children: React.ReactNode; +}) => { + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index bdc2084..38c2e4a 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -4,16 +4,19 @@ import { ThemeProvider as NextThemesProvider, type ThemeProviderProps, } from "next-themes"; +import { ThemeProvider as ColorThemeProvider } from "@/providers/color"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { TooltipProvider } from "@/components/ui/tooltip"; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/src/components/setting/items.tsx b/src/components/setting/items.tsx index 0cc0463..d5abd93 100644 --- a/src/components/setting/items.tsx +++ b/src/components/setting/items.tsx @@ -1,4 +1,4 @@ -import { Calendar, Home, Inbox, Search, Settings } from "lucide-react"; +import { Bell, Cable, Home, Palette } from "lucide-react"; // Menu items. export const userItems = [ @@ -36,10 +36,14 @@ export const siteItems = [ }, { id: "api", - icon: Home, + icon: Cable, }, { id: "notification", - icon: Home, + icon: Bell, + }, + { + id: "theme", + icon: Palette, }, ]; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/providers/color/index.tsx b/src/providers/color/index.tsx new file mode 100644 index 0000000..a005661 --- /dev/null +++ b/src/providers/color/index.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { Theme } from "./types"; + +export const defaultTheme: Theme = { + name: "default", + light: { + background: "hsl(0 0% 100%)", + foreground: "hsl(240 10% 3.9%)", + muted: "hsl(240 4.8% 95.9%)", + "muted-foreground": "hsl(240 3.8% 46.1%)", + popover: "hsl(0 0% 100%)", + "popover-foreground": "hsl(240 10% 3.9%)", + card: "hsl(0 0% 100%)", + "card-foreground": "hsl(240 10% 3.9%)", + border: "hsl(240 5.9% 90%)", + input: "hsl(240 5.9% 90%)", + primary: "hsl(240 5.9% 10%)", + "primary-foreground": "hsl(0 0% 98%)", + secondary: "hsl(240 4.8% 95.9%)", + "secondary-foreground": "hsl(240 5.9% 10%)", + accent: "hsl(240 4.8% 95.9%)", + "accent-foreground": "hsl(240 5.9% 10%)", + destructive: "hsl(0 84.2% 60.2%)", + "destructive-foreground": "hsl(0 0% 98%)", + ring: "hsl(240 5% 64.9%)", + radius: "0.5rem", + "chart-1": "hsl(220 70% 50%)", + "chart-2": "hsl(340 75% 55%)", + "chart-3": "hsl(30 80% 55%)", + "chart-4": "hsl(280 65% 60%)", + "chart-5": "hsl(160 60% 45%)", + "chart-6": "hsl(180 50% 50%)", + "chart-7": "hsl(216 50% 50%)", + "chart-8": "hsl(252 50% 50%)", + "chart-9": "hsl(288 50% 50%)", + "chart-10": "hsl(324 50% 50%)", + timing: "cubic-bezier(0.4, 0, 0.2, 1)", + "sidebar-background": "hsl(0 0% 98%)", + "sidebar-foreground": "hsl(240 5.3% 26.1%)", + "sidebar-primary": "hsl(240 5.9% 10%)", + "sidebar-primary-foreground": "hsl(0 0% 98%)", + "sidebar-accent": "hsl(240 4.8% 95.9%)", + "sidebar-accent-foreground": "hsl(240 5.9% 10%)", + "sidebar-border": "hsl(220 13% 91%)", + "sidebar-ring": "hsl(217.2 91.2% 59.8%)", + }, + dark: { + background: "hsl(240 10% 3.9%)", + foreground: "hsl(0 0% 98%)", + muted: "hsl(240 3.7% 15.9%)", + "muted-foreground": "hsl(240 5% 64.9%)", + popover: "hsl(240 10% 3.9%)", + "popover-foreground": "hsl(0 0% 98%)", + card: "hsl(240 10% 3.9%)", + "card-foreground": "hsl(0 0% 98%)", + border: "hsl(240 3.7% 15.9%)", + input: "hsl(240 3.7% 15.9%)", + primary: "hsl(0 0% 98%)", + "primary-foreground": "hsl(240 5.9% 10%)", + secondary: "hsl(240 3.7% 15.9%)", + "secondary-foreground": "hsl(0 0% 98%)", + accent: "hsl(240 3.7% 15.9%)", + "accent-foreground": "hsl(0 0% 98%)", + destructive: "hsl(0 62.8% 30.6%)", + "destructive-foreground": "hsl(0 85.7% 97.3%)", + ring: "hsl(240 3.7% 15.9%)", + radius: "0.5rem", + "chart-1": "hsl(220 70% 50%)", + "chart-2": "hsl(340 75% 55%)", + "chart-3": "hsl(30 80% 55%)", + "chart-4": "hsl(280 65% 60%)", + "chart-5": "hsl(160 60% 45%)", + "chart-6": "hsl(180 50% 50%)", + "chart-7": "hsl(216 50% 50%)", + "chart-8": "hsl(252 50% 50%)", + "chart-9": "hsl(288 50% 50%)", + "chart-10": "hsl(324 50% 50%)", + timing: "cubic-bezier(0.4, 0, 0.2, 1)", + "sidebar-background": "hsl(240 5.9% 10%)", + "sidebar-foreground": "hsl(240 4.8% 95.9%)", + "sidebar-primary": "hsl(224.3 76.3% 48%)", + "sidebar-primary-foreground": "hsl(0 0% 100%)", + "sidebar-accent": "hsl(240 3.7% 15.9%)", + "sidebar-accent-foreground": "hsl(240 4.8% 95.9%)", + "sidebar-border": "hsl(240 3.7% 15.9%)", + "sidebar-ring": "hsl(217.2 91.2% 59.8%)", + }, +}; + +type ThemeContextType = { + theme: Theme | null; + setTheme: (theme: Theme | null) => void; +}; + +const ThemeContext = createContext(undefined); + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} + +export function ThemeProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [theme, setTheme] = useState(null); + + useEffect(() => { + if (!theme) return; + + const remove = () => { + const oldStyle = document.getElementById("theme-vars"); + if (oldStyle) { + oldStyle.remove(); + } + }; + + const style = document.createElement("style"); + style.setAttribute("id", "theme-vars"); + + const lightCssVars = Object.entries(theme.light) + .map(([key, value]) => `--${key}: ${value};`) + .join("\n"); + const darkCssVars = Object.entries(theme.dark) + .map(([key, value]) => `--${key}: ${value};`) + .join("\n"); + style.innerHTML = ` + :root { + ${lightCssVars} + } + .dark { + ${darkCssVars} + } + `; + remove(); + document.head.appendChild(style); + + return () => { + style.remove(); + }; + }, [theme]); + + return ( + + {children} + + ); +} diff --git a/src/providers/color/script.tsx b/src/providers/color/script.tsx new file mode 100644 index 0000000..9722fcb --- /dev/null +++ b/src/providers/color/script.tsx @@ -0,0 +1,49 @@ +import type { DerivedTheme, Theme } from "./types"; + +const testTheme: Theme = { + name: "test", + light: { + primary: "red", + }, + dark: { + primary: "blue", + }, +}; +const getTheme = async (): Promise => { + const theme = testTheme; + const lightCssVars = Object.entries(theme.light) + .map(([key, value]) => { + const cssVar = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + return `--${cssVar}: ${value};`; + }) + .join("\n"); + const darkCssVars = Object.entries(theme.dark) + .map(([key, value]) => { + const cssVar = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + return `--${cssVar}: ${value};`; + }) + .join("\n"); + return { + name: theme.name, + light: lightCssVars, + dark: darkCssVars, + }; +}; + +export async function ThemeScript() { + const theme = await getTheme(); + const styleContent = ` + :root { + ${theme.light} + } + .dark { + ${theme.dark} + } + `; + return ( +