Add dark modle

This commit is contained in:
songtianlun 2025-07-28 23:26:50 +08:00
parent a58b9222c3
commit 6a6f1e0454
7 changed files with 620 additions and 142 deletions

View File

@ -1,65 +1,146 @@
@import "tailwindcss";
/* Light theme variables */
:root {
--background: #ffffff;
--foreground: #0f172a;
--muted: #f8fafc;
--muted-foreground: #64748b;
--border: #e2e8f0;
--input: #ffffff;
--primary: #0f172a;
--primary-foreground: #f8fafc;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--accent: #f1f5f9;
--accent-foreground: #0f172a;
--destructive: #ef4444;
--destructive-foreground: #fef2f2;
--ring: #0f172a;
--background: 255 255 255;
--foreground: 15 23 42;
--card: 255 255 255;
--card-foreground: 15 23 42;
--popover: 255 255 255;
--popover-foreground: 15 23 42;
--primary: 15 23 42;
--primary-foreground: 248 250 252;
--secondary: 241 245 249;
--secondary-foreground: 15 23 42;
--muted: 248 250 252;
--muted-foreground: 100 116 139;
--accent: 241 245 249;
--accent-foreground: 15 23 42;
--destructive: 239 68 68;
--destructive-foreground: 254 242 242;
--border: 226 232 240;
--input: 255 255 255;
--ring: 15 23 42;
--radius: 0.5rem;
}
/* Dark theme variables */
.dark {
--background: 2 6 23;
--foreground: 248 250 252;
--card: 15 23 42;
--card-foreground: 248 250 252;
--popover: 15 23 42;
--popover-foreground: 248 250 252;
--primary: 248 250 252;
--primary-foreground: 15 23 42;
--secondary: 30 41 59;
--secondary-foreground: 248 250 252;
--muted: 15 23 42;
--muted-foreground: 148 163 184;
--accent: 30 41 59;
--accent-foreground: 248 250 252;
--destructive: 220 38 38;
--destructive-foreground: 254 242 242;
--border: 30 41 59;
--input: 15 23 42;
--ring: 248 250 252;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-background: rgb(var(--background));
--color-foreground: rgb(var(--foreground));
--color-card: rgb(var(--card));
--color-card-foreground: rgb(var(--card-foreground));
--color-popover: rgb(var(--popover));
--color-popover-foreground: rgb(var(--popover-foreground));
--color-primary: rgb(var(--primary));
--color-primary-foreground: rgb(var(--primary-foreground));
--color-secondary: rgb(var(--secondary));
--color-secondary-foreground: rgb(var(--secondary-foreground));
--color-muted: rgb(var(--muted));
--color-muted-foreground: rgb(var(--muted-foreground));
--color-accent: rgb(var(--accent));
--color-accent-foreground: rgb(var(--accent-foreground));
--color-destructive: rgb(var(--destructive));
--color-destructive-foreground: rgb(var(--destructive-foreground));
--color-border: rgb(var(--border));
--color-input: rgb(var(--input));
--color-ring: rgb(var(--ring));
--radius: var(--radius);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #020617;
--foreground: #f8fafc;
--muted: #0f172a;
--muted-foreground: #94a3b8;
--border: #1e293b;
--input: #0f172a;
--primary: #f8fafc;
--primary-foreground: #0f172a;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--destructive: #dc2626;
--destructive-foreground: #fef2f2;
--ring: #f8fafc;
/* Base styles */
* {
border-color: rgb(var(--border));
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
background-color: rgb(var(--background));
color: rgb(var(--foreground));
font-family: var(--font-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
font-feature-settings: "rlig" 1, "calt" 1;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Selection styles */
::selection {
background-color: rgb(var(--primary) / 0.1);
color: rgb(var(--primary));
}
/* Focus styles */
:focus-visible {
outline: 2px solid rgb(var(--ring));
outline-offset: 2px;
}
/* Scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgb(var(--muted));
}
::-webkit-scrollbar-thumb {
background: rgb(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--muted-foreground) / 0.5);
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-track {
background: rgb(var(--muted));
}
.dark ::-webkit-scrollbar-thumb {
background: rgb(var(--muted-foreground) / 0.3);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgb(var(--muted-foreground) / 0.5);
}
/* Print styles */
@media print {
* {
background: transparent !important;
color: black !important;
box-shadow: none !important;
text-shadow: none !important;
}
}

View File

@ -1,20 +1,57 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/contexts/ThemeContext";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
preload: true,
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
preload: true,
display: "swap",
});
export const metadata: Metadata = {
title: "Prmbr - AI Prompt Studio",
description: "Build, manage and optimize your AI prompts with Prmbr",
keywords: ["AI", "prompt", "builder", "studio", "GPT", "ChatGPT", "Claude"],
authors: [{ name: "Prmbr Team" }],
creator: "Prmbr",
publisher: "Prmbr",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
manifest: "/manifest.json",
icons: {
icon: "/favicon.ico",
shortcut: "/favicon-16x16.png",
apple: "/apple-touch-icon.png",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#020617" },
],
};
export default function RootLayout({
@ -23,11 +60,32 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark:dark">
<html lang="en" suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.getItem('prmbr-theme') === 'dark' ||
(!localStorage.getItem('prmbr-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#020617')
} else {
document.documentElement.classList.remove('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#ffffff')
}
} catch (e) {}
`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-background text-foreground`}
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}
>
<ThemeProvider defaultTheme="system" storageKey="prmbr-theme">
{children}
</ThemeProvider>
</body>
</html>
);

View File

@ -18,13 +18,13 @@ export default function Home() {
if (user) {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen">
<Header />
<div className="max-w-4xl mx-auto px-4 py-12 text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
<h1 className="text-3xl font-bold text-foreground mb-4">
Welcome to your Prompt Studio!
</h1>
<p className="text-gray-600 mb-8">
<p className="text-muted-foreground mb-8">
Start building, testing, and managing your AI prompts.
</p>
<Button size="lg">
@ -37,17 +37,17 @@ export default function Home() {
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen">
<Header />
{/* Hero Section */}
<section className="bg-gradient-to-b from-blue-50 to-white">
<section className="bg-gradient-to-b from-muted/50 to-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24">
<div className="text-center">
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
<h1 className="text-4xl md:text-6xl font-bold text-foreground mb-6">
AI Prompt Studio
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
<p className="text-xl text-muted-foreground mb-8 max-w-3xl mx-auto">
Build, test, and manage your AI prompts with precision.
Version control, collaboration tools, and analytics in one professional platform.
</p>
@ -64,54 +64,54 @@ export default function Home() {
</section>
{/* Features Section */}
<section id="features" className="py-24 bg-gray-50">
<section id="features" className="py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
<h2 className="text-3xl font-bold text-foreground mb-4">
Everything you need to master prompts
</h2>
<p className="text-xl text-gray-600">
<p className="text-xl text-muted-foreground">
Professional tools for prompt engineering and management
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<Target className="h-12 w-12 text-blue-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<div className="bg-card p-6 rounded-lg shadow-sm border">
<Target className="h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Prompt Builder
</h3>
<p className="text-gray-600">
<p className="text-muted-foreground">
Intuitive interface for crafting and refining AI prompts with real-time preview.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<Layers className="h-12 w-12 text-blue-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<div className="bg-card p-6 rounded-lg shadow-sm border">
<Layers className="h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Version Control
</h3>
<p className="text-gray-600">
<p className="text-muted-foreground">
Track changes, compare versions, and rollback to previous iterations seamlessly.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<BarChart3 className="h-12 w-12 text-blue-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<div className="bg-card p-6 rounded-lg shadow-sm border">
<BarChart3 className="h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Test & Analytics
</h3>
<p className="text-gray-600">
<p className="text-muted-foreground">
Run tests, analyze performance, and optimize your prompts with detailed metrics.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<Zap className="h-12 w-12 text-blue-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<div className="bg-card p-6 rounded-lg shadow-sm border">
<Zap className="h-12 w-12 text-primary mb-4" />
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Team Collaboration
</h3>
<p className="text-gray-600">
<p className="text-muted-foreground">
Share prompts, collaborate with team members, and maintain quality standards.
</p>
</div>
@ -120,36 +120,36 @@ export default function Home() {
</section>
{/* Pricing Section */}
<section id="pricing" className="py-24 bg-white">
<section id="pricing" className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
<h2 className="text-3xl font-bold text-foreground mb-4">
Simple, transparent pricing
</h2>
<p className="text-xl text-gray-600">
<p className="text-xl text-muted-foreground">
Choose the plan that fits your needs
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200">
<div className="bg-card p-8 rounded-lg shadow-sm border">
<div className="text-center mb-6">
<h3 className="text-2xl font-bold text-gray-900 mb-2">Free</h3>
<div className="text-4xl font-bold text-gray-900 mb-2">$0</div>
<p className="text-gray-600">Perfect for getting started</p>
<h3 className="text-2xl font-bold text-card-foreground mb-2">Free</h3>
<div className="text-4xl font-bold text-card-foreground mb-2">$0</div>
<p className="text-muted-foreground">Perfect for getting started</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
20 prompts
<span className="text-card-foreground">20 prompts</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
3 versions per prompt
<span className="text-card-foreground">3 versions per prompt</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
$5 AI credits monthly
<span className="text-card-foreground">$5 AI credits monthly</span>
</li>
</ul>
<Button className="w-full" onClick={() => window.location.href = '/signup'}>
@ -157,34 +157,34 @@ export default function Home() {
</Button>
</div>
<div className="bg-blue-600 p-8 rounded-lg shadow-sm text-white relative">
<div className="absolute top-4 right-4 bg-white text-blue-600 px-3 py-1 rounded-full text-xs font-semibold">
<div className="bg-primary p-8 rounded-lg shadow-sm text-primary-foreground relative">
<div className="absolute top-4 right-4 bg-primary-foreground text-primary px-3 py-1 rounded-full text-xs font-semibold">
Popular
</div>
<div className="text-center mb-6">
<h3 className="text-2xl font-bold mb-2">Pro</h3>
<div className="text-4xl font-bold mb-2">$19.9</div>
<p className="text-blue-100">per month</p>
<p className="text-primary-foreground/80">per month</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-blue-200 mr-3" />
500 prompts
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>500 prompts</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-blue-200 mr-3" />
10 versions per prompt
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>10 versions per prompt</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-blue-200 mr-3" />
$20 AI credits monthly
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>$20 AI credits monthly</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-blue-200 mr-3" />
Priority support
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>Priority support</span>
</li>
</ul>
<Button variant="outline" className="w-full bg-white text-blue-600 hover:bg-gray-50" onClick={() => window.location.href = '/signup'}>
<Button variant="outline" className="w-full bg-primary-foreground text-primary hover:bg-primary-foreground/90" onClick={() => window.location.href = '/signup'}>
Start Pro Trial
</Button>
</div>
@ -193,14 +193,14 @@ export default function Home() {
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12">
<footer className="border-t py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center mb-4 md:mb-0">
<Zap className="h-8 w-8 text-blue-400" />
<span className="ml-2 text-xl font-bold">Prmbr</span>
<Zap className="h-8 w-8 text-primary" />
<span className="ml-2 text-xl font-bold text-foreground">Prmbr</span>
</div>
<div className="text-gray-400 text-sm">
<div className="text-muted-foreground text-sm">
© 2024 Prmbr. All rights reserved.
</div>
</div>

View File

@ -234,7 +234,7 @@ export default function ProfilePage() {
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<Header />
<div className="max-w-4xl mx-auto px-4 py-8">

View File

@ -3,6 +3,7 @@
import { useState } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { ThemeToggle, MobileThemeToggle } from '@/components/ui/theme-toggle'
import { Menu, X, Zap } from 'lucide-react'
export function Header() {
@ -10,26 +11,26 @@ export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<header className="bg-white border-b border-gray-200">
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center">
<Zap className="h-8 w-8 text-blue-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Prmbr</span>
<Zap className="h-8 w-8 text-primary" />
<span className="ml-2 text-xl font-bold text-foreground">Prmbr</span>
</div>
</div>
{/* Desktop Navigation */}
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
<a href="#features" className="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm font-medium">
<a href="#features" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
Features
</a>
<a href="#pricing" className="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm font-medium">
<a href="#pricing" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
Pricing
</a>
<a href="#showcase" className="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm font-medium">
<a href="#showcase" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
Showcase
</a>
</div>
@ -37,8 +38,10 @@ export function Header() {
{/* Desktop Auth */}
<div className="hidden md:block">
<div className="flex items-center space-x-2">
<ThemeToggle variant="dropdown" showLabel={false} />
{user ? (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" onClick={() => window.location.href = '/profile'}>
Profile
</Button>
@ -57,12 +60,15 @@ export function Header() {
</div>
)}
</div>
</div>
{/* Mobile menu button */}
<div className="md:hidden">
{/* Mobile controls */}
<div className="md:hidden flex items-center space-x-2">
<ThemeToggle variant="button" />
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-gray-600 hover:text-gray-900 p-2"
className="text-muted-foreground hover:text-foreground p-2 transition-colors"
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
@ -72,20 +78,26 @@ export function Header() {
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 border-t border-gray-200">
<a href="#features" className="block text-gray-600 hover:text-gray-900 px-3 py-2 text-base font-medium">
<div className="px-2 pt-2 pb-3 space-y-1 border-t">
<a href="#features" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
Features
</a>
<a href="#pricing" className="block text-gray-600 hover:text-gray-900 px-3 py-2 text-base font-medium">
<a href="#pricing" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
Pricing
</a>
<a href="#showcase" className="block text-gray-600 hover:text-gray-900 px-3 py-2 text-base font-medium">
<a href="#showcase" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
Showcase
</a>
<div className="pt-4 pb-2">
{/* Mobile Theme Toggle */}
<div className="pt-4 pb-2 border-t">
<MobileThemeToggle />
</div>
<div className="pt-2 pb-2 border-t">
{user ? (
<div className="space-y-2">
<Button variant="ghost" className="w-full" onClick={() => window.location.href = '/profile'}>
<Button variant="ghost" className="w-full justify-start" onClick={() => window.location.href = '/profile'}>
Profile
</Button>
<Button variant="outline" className="w-full" onClick={signOut}>

View File

@ -0,0 +1,214 @@
'use client'
import { useState, useEffect } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Monitor, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ThemeToggleProps {
className?: string
showLabel?: boolean
variant?: 'button' | 'dropdown'
}
const themeOptions = [
{
value: 'light' as const,
label: { en: 'Light', zh: '浅色' },
icon: Sun,
},
{
value: 'dark' as const,
label: { en: 'Dark', zh: '深色' },
icon: Moon,
},
{
value: 'system' as const,
label: { en: 'System', zh: '跟随系统' },
icon: Monitor,
},
]
export function ThemeToggle({
className,
showLabel = false,
variant = 'button'
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [currentLanguage, setCurrentLanguage] = useState<'en' | 'zh'>('en')
// Prevent hydration mismatch
useEffect(() => {
setMounted(true)
// Get language from user metadata or browser
const getUserLanguage = async () => {
// Try to get from user profile first (if implemented)
const browserLanguage = navigator.language.startsWith('zh') ? 'zh' : 'en'
setCurrentLanguage(browserLanguage)
}
getUserLanguage()
}, [])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (!target.closest('[data-theme-toggle]')) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [isOpen])
if (!mounted) {
return (
<Button
variant="ghost"
size="sm"
className={cn("w-9 h-9 p-0", className)}
disabled
>
<Monitor className="h-4 w-4" />
<span className="sr-only">Loading theme...</span>
</Button>
)
}
const currentThemeOption = themeOptions.find(option => option.value === theme)
const CurrentIcon = currentThemeOption?.icon || Monitor
if (variant === 'button') {
const nextTheme = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
return (
<Button
variant="ghost"
size="sm"
className={cn("w-9 h-9 p-0", className)}
onClick={() => setTheme(nextTheme)}
title={`Current: ${currentThemeOption?.label[currentLanguage] || 'System'}`}
>
<CurrentIcon className="h-4 w-4 transition-transform duration-200" />
<span className="sr-only">
Toggle theme (current: {currentThemeOption?.label[currentLanguage] || 'System'})
</span>
</Button>
)
}
return (
<div className="relative" data-theme-toggle>
<Button
variant="ghost"
size="sm"
className={cn(
"h-9 gap-2 px-3",
showLabel ? "w-auto" : "w-9 px-0",
className
)}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="menu"
>
<CurrentIcon className="h-4 w-4" />
{showLabel && (
<>
<span className="text-sm">
{currentThemeOption?.label[currentLanguage] || 'System'}
</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform",
isOpen && "rotate-180"
)} />
</>
)}
<span className="sr-only">Theme options</span>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-1 w-36 rounded-md border border-border bg-popover p-1 shadow-md z-50">
{themeOptions.map((option) => {
const Icon = option.icon
const isSelected = theme === option.value
return (
<button
key={option.value}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none",
isSelected && "bg-accent text-accent-foreground"
)}
onClick={() => {
setTheme(option.value)
setIsOpen(false)
}}
role="menuitem"
>
<Icon className="h-4 w-4" />
<span>{option.label[currentLanguage]}</span>
{isSelected && (
<div className="ml-auto w-1 h-1 rounded-full bg-current" />
)}
</button>
)
})}
</div>
)}
</div>
)
}
// Simplified version for mobile menu
export function MobileThemeToggle({ className }: { className?: string }) {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [currentLanguage, setCurrentLanguage] = useState<'en' | 'zh'>('en')
useEffect(() => {
setMounted(true)
const browserLanguage = navigator.language.startsWith('zh') ? 'zh' : 'en'
setCurrentLanguage(browserLanguage)
}, [])
if (!mounted) {
return null
}
return (
<div className={cn("space-y-1", className)}>
<div className="text-sm font-medium text-muted-foreground px-3 py-2">
{currentLanguage === 'zh' ? '主题' : 'Theme'}
</div>
{themeOptions.map((option) => {
const Icon = option.icon
const isSelected = theme === option.value
return (
<button
key={option.value}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground"
)}
onClick={() => setTheme(option.value)}
>
<Icon className="h-4 w-4" />
<span>{option.label[currentLanguage]}</span>
</button>
)
})}
</div>
)
}

View File

@ -0,0 +1,113 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: 'light' | 'dark'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function useTheme() {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'prmbr-theme',
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(defaultTheme)
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light')
// Initialize theme from localStorage or default
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey) as Theme
if (stored && ['light', 'dark', 'system'].includes(stored)) {
setThemeState(stored)
}
} catch (error) {
console.warn('Failed to load theme from localStorage:', error)
}
}, [storageKey])
// Update resolved theme based on current theme and system preference
useEffect(() => {
const updateResolvedTheme = () => {
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
setResolvedTheme(systemTheme)
} else {
setResolvedTheme(theme)
}
}
updateResolvedTheme()
// Listen for system theme changes
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => updateResolvedTheme()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}
}, [theme])
// Apply theme to document
useEffect(() => {
const root = document.documentElement
// Remove existing theme classes
root.classList.remove('light', 'dark')
// Add resolved theme class
root.classList.add(resolvedTheme)
// Set color-scheme for native browser elements
root.style.colorScheme = resolvedTheme
// Update meta theme-color for mobile browsers
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
if (metaThemeColor) {
metaThemeColor.setAttribute('content', resolvedTheme === 'dark' ? '#020617' : '#ffffff')
}
}, [resolvedTheme])
const setTheme = (newTheme: Theme) => {
try {
localStorage.setItem(storageKey, newTheme)
setThemeState(newTheme)
} catch (error) {
console.warn('Failed to save theme to localStorage:', error)
setThemeState(newTheme)
}
}
const value = {
theme,
setTheme,
resolvedTheme,
}
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}