Implement theme provider and settings page for customizable appearance
This commit is contained in:
81
app/components/ThemeProvider.tsx
Normal file
81
app/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'blue' | 'violet';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const 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;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>('blue');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Load theme settings from localStorage on component mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
|
||||
if (savedTheme && (savedTheme === 'blue' || savedTheme === 'violet')) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Apply theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove existing theme classes
|
||||
root.classList.remove('theme-blue', 'theme-violet', 'dark');
|
||||
|
||||
// Add new theme class
|
||||
root.classList.add(`theme-${theme}`);
|
||||
|
||||
// Always follow system preference for dark mode
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const applySystemTheme = () => {
|
||||
root.classList.toggle('dark', mediaQuery.matches);
|
||||
};
|
||||
|
||||
applySystemTheme();
|
||||
mediaQuery.addEventListener('change', applySystemTheme);
|
||||
|
||||
// Save theme to localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Cleanup listener
|
||||
return () => mediaQuery.removeEventListener('change', applySystemTheme);
|
||||
}, [theme, mounted]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
setTheme,
|
||||
}}
|
||||
>
|
||||
<div className={`theme-${theme}`}>
|
||||
{children}
|
||||
</div>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
120
app/globals.css
120
app/globals.css
@@ -41,6 +41,126 @@ body {
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--hover: 240 27% 11%;
|
||||
}
|
||||
|
||||
|
||||
/* Blue Theme Dark */
|
||||
.theme-blue.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
--hover: 240 27% 11%;
|
||||
}
|
||||
|
||||
/* Violet Theme Dark */
|
||||
.theme-violet.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
--primary: 263.4 70% 50.4%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 263.4 70% 50.4%;
|
||||
}
|
||||
|
||||
/* Default dark mode (fallback) */
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--hover: 240 27% 11%;
|
||||
}
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
@@ -5,6 +5,7 @@ import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { AudioPlayerProvider } from "./components/AudioPlayerContext";
|
||||
import { NavidromeProvider } from "./components/NavidromeContext";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { Metadata } from "next";
|
||||
import type { Viewport } from 'next';
|
||||
import Ihateserverside from './components/ihateserverside';
|
||||
@@ -54,16 +55,38 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiase dark bg-background`}>
|
||||
<NavidromeProvider>
|
||||
<AudioPlayerProvider>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
<Ihateserverside>
|
||||
{children}
|
||||
</Ihateserverside>
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeProvider>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const theme = (savedTheme === 'blue' || savedTheme === 'violet') ? savedTheme : 'blue';
|
||||
|
||||
// Apply theme class
|
||||
document.documentElement.classList.add('theme-' + theme);
|
||||
|
||||
// Apply dark mode based on system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}>
|
||||
<ThemeProvider>
|
||||
<NavidromeProvider>
|
||||
<AudioPlayerProvider>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
<Ihateserverside>
|
||||
{children}
|
||||
</Ihateserverside>
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<Label>Theme</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue>Light</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="container mx-auto p-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">Customize your music experience</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
Customize how the application looks and feels
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-select">Color Theme</Label>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="blue">Blue</SelectItem>
|
||||
<SelectItem value="violet">Violet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p><strong>Theme:</strong> Choose between blue and violet color schemes</p>
|
||||
<p><strong>Dark Mode:</strong> Automatically follows your system preferences</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>
|
||||
See how your theme looks
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4 p-4 border rounded-lg">
|
||||
<div className="w-12 h-12 bg-primary rounded-full flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-semibold">♪</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">Sample Song Title</p>
|
||||
<p className="text-sm text-muted-foreground">Sample Artist</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">3:42</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-8 w-16 bg-secondary rounded"></div>
|
||||
<div className="h-8 w-16 bg-accent rounded"></div>
|
||||
<div className="h-8 w-16 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user