In Metropolis Quantum, the city adapts to the needs of its residents. During the day, the glass facades of buildings let in natural sunlight, and holographic displays operate in high-brightness mode. When dusk falls, the city automatically switches to night mode - lighting becomes warmer and less intense, holographic displays switch to dark mode to avoid straining residents' eyes.
In modern web applications, a similar mechanism - Dark Mode - has become not just an aesthetic element but a standard of usability and accessibility. In this chapter, we will learn ways to implement dark mode and manage user preferences in Next.js 15 applications.
In addition to dark mode, it is also worth considering other preferences:
Next.js 15 offers several approaches to implementing dark mode:
The simplest approach uses CSS variables and class selectors:
1/* app/globals.css */
2:root {
3 /* Zmienne dla trybu jasnego */
4 --background: #ffffff;
5 --foreground: #121212;
6 --primary: #0070f3;
7 --secondary: #6b7280;
8 --accent: #8b5cf6;
9 --muted: #f3f4f6;
10 --border: #e5e7eb;
11}
12
13/* Klasa dla trybu ciemnego */
14.dark-theme {
15 --background: #121212;
16 --foreground: #f3f4f6;
17 --primary: #3b82f6;
18 --secondary: #9ca3af;
19 --accent: #a78bfa;
20 --muted: #1f2937;
21 --border: #374151;
22}
23
24body {
25 background-color: var(--background);
26 color: var(--foreground);
27 transition: background-color 0.3s ease, color 0.3s ease;
28}
29
30.card {
31 background-color: var(--muted);
32 border: 1px solid var(--border);
33 color: var(--foreground);
34}
35
36.button-primary {
37 background-color: var(--primary);
38 color: white;
39}1// components/ThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { MoonIcon, SunIcon } from '@/components/Icons';
6import styles from './ThemeToggle.module.css';
7
8export function ThemeToggle() {
9 // Start with a neutral value to avoid flash during hydration
10 const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);
11
12 useEffect(() => {
13 // Check system preferences
14 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
15
16 // Check saved user preferences
17 const savedTheme = localStorage.getItem('theme');
18 const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
19
20 setIsDarkMode(initialTheme === 'dark');
21 document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
22 }, []);
23
24 // Efekt uruchamiany przy zmianie stanu
25 useEffect(() => {
26 if (isDarkMode === undefined) return;
27
28 document.documentElement.classList.toggle('dark-theme', isDarkMode);
29 localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
30 }, [isDarkMode]);
31
32 // Do not render the button before determining initial state
33 if (isDarkMode === undefined) return null;
34
35 return (
36 <button
37 className={styles.themeToggle}
38 onClick={() => setIsDarkMode(!isDarkMode)}
39 aria-label={isDarkMode ? 'Switch to light theme' : 'Switch to dark theme'}
40 >
41 {isDarkMode ? <SunIcon /> : <MoonIcon />}
42 </button>
43 );
44}1/* components/ThemeToggle.module.css */
2.themeToggle {
3 background: none;
4 border: none;
5 width: 40px;
6 height: 40px;
7 border-radius: 50%;
8 display: flex;
9 align-items: center;
10 justify-content: center;
11 cursor: pointer;
12 color: var(--foreground);
13 transition: background-color 0.3s ease;
14}
15
16.themeToggle:hover {
17 background-color: var(--muted);
18}1// contexts/ThemeContext.tsx
2'use client';
3
4import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
5
6type Theme = 'light' | 'dark';
7
8interface ThemeContextType {
9 theme: Theme;
10 setTheme: (theme: Theme) => void;
11 toggleTheme: () => void;
12}
13
14const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
15
16export function ThemeProvider({ children }: { children: ReactNode }) {
17 const [theme, setTheme] = useState<Theme>('light');
18
19 // Initialize theme based on system preferences and saved settings
20 useEffect(() => {
21 // Check if we are in a browser environment
22 if (typeof window !== 'undefined') {
23 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
24 const savedTheme = localStorage.getItem('theme') as Theme | null;
25
26 const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
27 setTheme(initialTheme);
28 document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
29 }
30 }, []);
31
32 // Efekt uruchamiany przy zmianie tematu
33 useEffect(() => {
34 if (typeof window !== 'undefined') {
35 document.documentElement.classList.toggle('dark-theme', theme === 'dark');
36 localStorage.setItem('theme', theme);
37 }
38 }, [theme]);
39
40 // Function to toggle theme
41 const toggleTheme = () => {
42 setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
43 };
44
45 return (
46 <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
47 {children}
48 </ThemeContext.Provider>
49 );
50}
51
52// Hook to use theme context
53export function useTheme() {
54 const context = useContext(ThemeContext);
55 if (context === undefined) {
56 throw new Error('useTheme must be used within a ThemeProvider');
57 }
58 return context;
59}1// app/layout.tsx
2import { ThemeProvider } from '@/contexts/ThemeContext';
3import './globals.css';
4import type { Metadata } from 'next';
5
6export const metadata: Metadata = {
7 title: 'Metropolis Quantum',
8 description: 'The city of the future at your fingertips',
9};
10
11export default function RootLayout({
12 children,
13}: {
14 children: React.ReactNode;
15}) {
16 return (
17 <html lang="pl">
18 <body>
19 <ThemeProvider>
20 {children}
21 </ThemeProvider>
22 </body>
23 </html>
24 );
25}1// components/Header.tsx
2'use client';
3
4import { useTheme } from '@/contexts/ThemeContext';
5import Link from 'next/link';
6import styles from './Header.module.css';
7
8export function Header() {
9 const { theme, toggleTheme } = useTheme();
10
11 return (
12 <header className={styles.header}>
13 <div className={styles.logo}>
14 <Link href="/">Metropolis Quantum</Link>
15 </div>
16 <nav className={styles.nav}>
17 <ul>
18 <li><Link href="/about">O nas</Link></li>
19 <li><Link href="/services">Services</Link></li>
20 <li><Link href="/contact">Kontakt</Link></li>
21 </ul>
22 </nav>
23 <button
24 className={styles.themeToggle}
25 onClick={toggleTheme}
26 aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
27 >
28 {theme === 'dark' ? '☀️' : '🌙'}
29 </button>
30 </header>
31 );
32}next-themes is a popular library that simplifies dark mode implementation:1npm install next-themes1// app/providers.tsx
2'use client';
3
4import { ThemeProvider } from 'next-themes';
5
6export function Providers({ children }: { children: React.ReactNode }) {
7 return (
8 <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
9 {children}
10 </ThemeProvider>
11 );
12}1// app/layout.tsx
2import { Providers } from './providers';
3import './globals.css';
4import type { Metadata } from 'next';
5
6export const metadata: Metadata = {
7 title: 'Metropolis Quantum',
8 description: 'The city of the future at your fingertips',
9};
10
11export default function RootLayout({
12 children,
13}: {
14 children: React.ReactNode;
15}) {
16 return (
17 <html lang="pl" suppressHydrationWarning>
18 <body>
19 <Providers>{children}</Providers>
20 </body>
21 </html>
22 );
23}1/* app/globals.css */
2:root {
3 --background: #ffffff;
4 --foreground: #121212;
5 /* Inne zmienne dla jasnego motywu */
6}
7
8.dark {
9 --background: #121212;
10 --foreground: #f3f4f6;
11 /* Inne zmienne dla ciemnego motywu */
12}
13
14body {
15 background-color: var(--background);
16 color: var(--foreground);
17 transition: background-color 0.3s ease, color 0.3s ease;
18}1// components/ThemeSwitch.tsx
2'use client';
3
4import { useTheme } from 'next-themes';
5import { useEffect, useState } from 'react';
6
7export function ThemeSwitch() {
8 const [mounted, setMounted] = useState(false);
9 const { theme, setTheme } = useTheme();
10
11 // Efekt uruchamiany po montowaniu komponentu
12 useEffect(() => {
13 setMounted(true);
14 }, []);
15
16 if (!mounted) {
17 return null; // Nie renderuj podczas pierwszego renderowania (SSR)
18 }
19
20 return (
21 <select
22 value={theme}
23 onChange={(e) => setTheme(e.target.value)}
24 className="bg-transparent px-4 py-2 rounded border border-gray-300 dark:border-gray-700"
25 >
26 <option value="system">Ustawienia systemowe</option>
27 <option value="light">Jasny</option>
28 <option value="dark">Ciemny</option>
29 </select>
30 );
31}1// contexts/ThemeContext.tsx
2'use client';
3
4import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
5
6type Theme = 'light' | 'dark' | 'sepia' | 'forest' | 'ocean';
7
8interface ThemeContextType {
9 theme: Theme;
10 setTheme: (theme: Theme) => void;
11}
12
13const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
14
15export function ThemeProvider({ children }: { children: ReactNode }) {
16 const [theme, setTheme] = useState<Theme>('light');
17
18 // Inicjalizacja tematu
19 useEffect(() => {
20 if (typeof window !== 'undefined') {
21 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
22 const savedTheme = localStorage.getItem('theme') as Theme | null;
23
24 const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
25 setTheme(initialTheme);
26
27 // Remove all theme classes
28 document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
29 // Add current theme class
30 document.documentElement.classList.add(`theme-${initialTheme}`);
31 }
32 }, []);
33
34 // Efekt uruchamiany przy zmianie tematu
35 useEffect(() => {
36 if (typeof window !== 'undefined') {
37 // Remove all theme classes
38 document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
39 // Add current theme class
40 document.documentElement.classList.add(`theme-${theme}`);
41 localStorage.setItem('theme', theme);
42 }
43 }, [theme]);
44
45 return (
46 <ThemeContext.Provider value={{ theme, setTheme }}>
47 {children}
48 </ThemeContext.Provider>
49 );
50}
51
52export function useTheme() {
53 const context = useContext(ThemeContext);
54 if (context === undefined) {
55 throw new Error('useTheme must be used within a ThemeProvider');
56 }
57 return context;
58}1/* app/globals.css */
2:root {
3 /* Podstawowe zmienne */
4}
5
6/* Light theme (default) */
7.theme-light {
8 --background: #ffffff;
9 --foreground: #121212;
10 --primary: #0070f3;
11 --secondary: #6b7280;
12 --accent: #8b5cf6;
13 --muted: #f3f4f6;
14 --border: #e5e7eb;
15}
16
17/* Ciemny motyw */
18.theme-dark {
19 --background: #121212;
20 --foreground: #f3f4f6;
21 --primary: #3b82f6;
22 --secondary: #9ca3af;
23 --accent: #a78bfa;
24 --muted: #1f2937;
25 --border: #374151;
26}
27
28/* Motyw sepia (przyjazny dla oczu) */
29.theme-sepia {
30 --background: #f9f5eb;
31 --foreground: #433422;
32 --primary: #9c6644;
33 --secondary: #7d6e5d;
34 --accent: #c27b57;
35 --muted: #efe8d9;
36 --border: #d3c6ad;
37}
38
39/* Forest theme */
40.theme-forest {
41 --background: #1e2a20;
42 --foreground: #e9f0db;
43 --primary: #52b788;
44 --secondary: #74c69d;
45 --accent: #95d5b2;
46 --muted: #2d3a2e;
47 --border: #40534e;
48}
49
50/* Motyw oceaniczny */
51.theme-ocean {
52 --background: #0c2333;
53 --foreground: #e0f2fe;
54 --primary: #0ea5e9;
55 --secondary: #38bdf8;
56 --accent: #7dd3fc;
57 --muted: #164e63;
58 --border: #0369a1;
59}
60
61body {
62 background-color: var(--background);
63 color: var(--foreground);
64 transition: background-color 0.3s ease, color 0.3s ease;
65}1/* app/globals.css */
2body {
3 background-color: var(--background);
4 color: var(--foreground);
5 transition:
6 background-color 0.5s ease,
7 color 0.5s ease,
8 border-color 0.5s ease,
9 box-shadow 0.5s ease;
10}
11
12/* Animation for nested elements */
13* {
14 transition:
15 background-color 0.5s ease,
16 color 0.5s ease,
17 border-color 0.5s ease,
18 fill 0.5s ease,
19 stroke 0.5s ease;
20}Tailwind CSS ma wbudowane wsparcie dla trybu ciemnego:
1// components/Card.tsx
2export function Card({ title, content }) {
3 return (
4 <div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-900 border border-gray-200 dark:border-gray-700">
5 <h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{title}</h3>
6 <p className="text-gray-700 dark:text-gray-300">{content}</p>
7 </div>
8 );
9}1// utils/userPreferences.ts
2export interface UserPreferences {
3 theme: 'light' | 'dark' | 'system';
4 fontSize: 'small' | 'medium' | 'large';
5 reducedMotion: boolean;
6 highContrast: boolean;
7 language: string;
8}
9
10const defaultPreferences: UserPreferences = {
11 theme: 'system',
12 fontSize: 'medium',
13 reducedMotion: false,
14 highContrast: false,
15 language: 'pl',
16};
17
18export function getUserPreferences(): UserPreferences {
19 if (typeof window === 'undefined') {
20 return defaultPreferences;
21 }
22
23 try {
24 const savedPreferences = localStorage.getItem('userPreferences');
25 return savedPreferences
26 ? { ...defaultPreferences, ...JSON.parse(savedPreferences) }
27 : defaultPreferences;
28 } catch (error) {
29 console.error('Error reading user preferences:', error);
30 return defaultPreferences;
31 }
32}
33
34export function saveUserPreferences(preferences: Partial<UserPreferences>): void {
35 if (typeof window === 'undefined') {
36 return;
37 }
38
39 try {
40 const currentPreferences = getUserPreferences();
41 const updatedPreferences = { ...currentPreferences, ...preferences };
42 localStorage.setItem('userPreferences', JSON.stringify(updatedPreferences));
43 } catch (error) {
44 console.error('Error saving user preferences:', error);
45 }
46}Cookies can be used to store preferences between sessions and are available on both client and server:
1// lib/cookies.ts
2import { parseCookies, setCookie } from 'nookies';
3import { UserPreferences, defaultPreferences } from './userPreferences';
4
5export function getUserPreferencesFromCookies(ctx?: any): UserPreferences {
6 const cookies = parseCookies(ctx);
7
8 if (!cookies.userPreferences) {
9 return defaultPreferences;
10 }
11
12 try {
13 return { ...defaultPreferences, ...JSON.parse(cookies.userPreferences) };
14 } catch (error) {
15 console.error('Error parsing user preferences from cookies:', error);
16 return defaultPreferences;
17 }
18}
19
20export function saveUserPreferencesToCookies(
21 preferences: Partial<UserPreferences>,
22 ctx?: any
23): void {
24 const currentPreferences = getUserPreferencesFromCookies(ctx);
25 const updatedPreferences = { ...currentPreferences, ...preferences };
26
27 setCookie(ctx, 'userPreferences', JSON.stringify(updatedPreferences), {
28 maxAge: 365 * 24 * 60 * 60, // 1 rok
29 path: '/',
30 sameSite: 'strict',
31 secure: process.env.NODE_ENV === 'production',
32 });
33}1// components/UserPreferencesPanel.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { UserPreferences, getUserPreferences, saveUserPreferences } from '@/utils/userPreferences';
6import styles from './UserPreferencesPanel.module.css';
7
8export function UserPreferencesPanel() {
9 const [preferences, setPreferences] = useState<UserPreferences | null>(null);
10 const [isOpen, setIsOpen] = useState(false);
11
12 useEffect(() => {
13 setPreferences(getUserPreferences());
14 }, []);
15
16 if (!preferences) return null;
17
18 const updatePreference = <K extends keyof UserPreferences>(
19 key: K,
20 value: UserPreferences[K]
21 ) => {
22 const updatedPreferences = { ...preferences, [key]: value };
23 setPreferences(updatedPreferences);
24 saveUserPreferences({ [key]: value });
25
26 // Zastosuj preferencje natychmiast
27 applyPreferences(key, value);
28 };
29
30 const applyPreferences = <K extends keyof UserPreferences>(
31 key: K,
32 value: UserPreferences[K]
33 ) => {
34 switch (key) {
35 case 'theme':
36 const theme = value as 'light' | 'dark' | 'system';
37 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
38 const effectiveTheme = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
39
40 document.documentElement.classList.remove('theme-light', 'theme-dark');
41 document.documentElement.classList.add(`theme-${effectiveTheme}`);
42 break;
43
44 case 'fontSize':
45 document.documentElement.style.fontSize = {
46 small: '14px',
47 medium: '16px',
48 large: '18px',
49 }[value as string] || '16px';
50 break;
51
52 case 'reducedMotion':
53 document.documentElement.classList.toggle('reduce-motion', value as boolean);
54 break;
55
56 case 'highContrast':
57 document.documentElement.classList.toggle('high-contrast', value as boolean);
58 break;
59
60 case 'language':
61 // In a real application - here you would implement language change
62 break;
63 }
64 };
65
66 return (
67 <>
68 <button
69 className={styles.settingsButton}
70 onClick={() => setIsOpen(!isOpen)}
71 aria-expanded={isOpen}
72 >
73 ⚙️ Preferencje
74 </button>
75
76 {isOpen && (
77 <div className={styles.panel}>
78 <h2>User Preferences</h2>
79
80 <div className={styles.preferenceGroup}>
81 <h3>Motyw</h3>
82 <div className={styles.options}>
83 <label>
84 <input
85 type="radio"
86 name="theme"
87 value="light"
88 checked={preferences.theme === 'light'}
89 onChange={() => updatePreference('theme', 'light')}
90 />
91 Jasny
92 </label>
93
94 <label>
95 <input
96 type="radio"
97 name="theme"
98 value="dark"
99 checked={preferences.theme === 'dark'}
100 onChange={() => updatePreference('theme', 'dark')}
101 />
102 Ciemny
103 </label>
104
105 <label>
106 <input
107 type="radio"
108 name="theme"
109 value="system"
110 checked={preferences.theme === 'system'}
111 onChange={() => updatePreference('theme', 'system')}
112 />
113 Zgodny z systemem
114 </label>
115 </div>
116 </div>
117
118 <div className={styles.preferenceGroup}>
119 <h3>Rozmiar tekstu</h3>
120 <div className={styles.options}>
121 <label>
122 <input
123 type="radio"
124 name="fontSize"
125 value="small"
126 checked={preferences.fontSize === 'small'}
127 onChange={() => updatePreference('fontSize', 'small')}
128 />
129 Small
130 </label>
131
132 <label>
133 <input
134 type="radio"
135 name="fontSize"
136 value="medium"
137 checked={preferences.fontSize === 'medium'}
138 onChange={() => updatePreference('fontSize', 'medium')}
139 />
140 Medium
141 </label>
142
143 <label>
144 <input
145 type="radio"
146 name="fontSize"
147 value="large"
148 checked={preferences.fontSize === 'large'}
149 onChange={() => updatePreference('fontSize', 'large')}
150 />
151 Large
152 </label>
153 </div>
154 </div>
155
156 <div className={styles.preferenceGroup}>
157 <h3>Accessibility</h3>
158 <div className={styles.options}>
159 <label>
160 <input
161 type="checkbox"
162 checked={preferences.reducedMotion}
163 onChange={(e) => updatePreference('reducedMotion', e.target.checked)}
164 />
165 Zredukowane animacje
166 </label>
167
168 <label>
169 <input
170 type="checkbox"
171 checked={preferences.highContrast}
172 onChange={(e) => updatePreference('highContrast', e.target.checked)}
173 />
174 Wysoki kontrast
175 </label>
176 </div>
177 </div>
178
179 <div className={styles.preferenceGroup}>
180 <h3>Language</h3>
181 <select
182 value={preferences.language}
183 onChange={(e) => updatePreference('language', e.target.value)}
184 className={styles.select}
185 >
186 <option value="pl">Polski</option>
187 <option value="en">English</option>
188 <option value="de">Deutsch</option>
189 <option value="fr">Français</option>
190 </select>
191 </div>
192
193 <button
194 className={styles.closeButton}
195 onClick={() => setIsOpen(false)}
196 >
197 Zamknij
198 </button>
199 </div>
200 )}
201 </>
202 );
203}1/* app/globals.css */
2:root {
3 /* Podstawowe zmienne */
4 --background: #ffffff;
5 --foreground: #121212;
6 /* ... (inne podstawowe kolory) ... */
7
8 /* Zmienne dla rozmiaru tekstu */
9 --font-size-base: 16px;
10 --font-size-small: 0.875rem;
11 --font-size-medium: 1rem;
12 --font-size-large: 1.25rem;
13 --font-size-xl: 1.5rem;
14 --font-size-2xl: 2rem;
15
16 /* Variables for spacing */
17 --spacing-xs: 0.25rem;
18 --spacing-sm: 0.5rem;
19 --spacing-md: 1rem;
20 --spacing-lg: 1.5rem;
21 --spacing-xl: 2rem;
22
23 /* Zmienne dla animacji */
24 --transition-speed: 0.3s;
25}
26
27/* Tryb ciemny */
28.theme-dark {
29 --background: #121212;
30 --foreground: #f3f4f6;
31 /* ... (inne kolory dla trybu ciemnego) ... */
32}
33
34/* Wysoki kontrast */
35.high-contrast {
36 --background: #000000;
37 --foreground: #ffffff;
38 --primary: #ffff00;
39 --secondary: #00ffff;
40 --accent: #ff00ff;
41 --muted: #333333;
42 --border: #ffffff;
43}
44
45/* Large text */
46html[data-text-size="large"] {
47 --font-size-base: 18px;
48 --font-size-small: 1rem;
49 --font-size-medium: 1.125rem;
50 --font-size-large: 1.375rem;
51 --font-size-xl: 1.75rem;
52 --font-size-2xl: 2.25rem;
53}
54
55/* Zredukowane animacje */
56.reduce-motion * {
57 animation-duration: 0.001s !important;
58 transition-duration: 0.001s !important;
59 scroll-behavior: auto !important;
60}
61
62/* Podstawowe style */
63html {
64 font-size: var(--font-size-base);
65}
66
67body {
68 background-color: var(--background);
69 color: var(--foreground);
70 font-size: var(--font-size-medium);
71 transition:
72 background-color var(--transition-speed) ease,
73 color var(--transition-speed) ease;
74}
75
76a {
77 color: var(--primary);
78 transition: color var(--transition-speed) ease;
79}
80
81.reduce-motion a {
82 transition: none;
83}1/* components/Button.module.css */
2.button {
3 background-color: var(--primary);
4 color: white;
5 border-radius: 0.375rem;
6 font-weight: 500;
7 padding: var(--spacing-sm) var(--spacing-md);
8 font-size: var(--font-size-medium);
9 height: calc(var(--font-size-medium) * 2.5);
10 line-height: 1;
11 display: inline-flex;
12 align-items: center;
13 justify-content: center;
14 transition: background-color var(--transition-speed) ease;
15}
16
17.button:hover {
18 background-color: var(--primary-dark);
19}
20
21.reduce-motion .button {
22 transition: none;
23}For applications with authentication, user preferences can be stored on the server:
1// app/api/preferences/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getServerSession } from 'next-auth/next';
4import { authOptions } from '@/app/api/auth/[...nextauth]/route';
5import { prisma } from '@/lib/prisma';
6
7export async function GET(req: NextRequest) {
8 const session = await getServerSession(authOptions);
9
10 if (!session || !session.user) {
11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 }
13
14 try {
15 const preferences = await prisma.userPreferences.findUnique({
16 where: { userId: session.user.id },
17 });
18
19 return NextResponse.json(preferences);
20 } catch (error) {
21 return NextResponse.json({ error: 'Failed to fetch preferences' }, { status: 500 });
22 }
23}
24
25export async function POST(req: NextRequest) {
26 const session = await getServerSession(authOptions);
27
28 if (!session || !session.user) {
29 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
30 }
31
32 try {
33 const data = await req.json();
34 const preferences = await prisma.userPreferences.upsert({
35 where: { userId: session.user.id },
36 update: data,
37 create: {
38 userId: session.user.id,
39 ...data,
40 },
41 });
42
43 return NextResponse.json(preferences);
44 } catch (error) {
45 return NextResponse.json({ error: 'Failed to update preferences' }, { status: 500 });
46 }
47}1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6 const response = NextResponse.next();
7
8 // If this is the first request, check if a preference cookie exists
9 const hasPrefsCookie = request.cookies.has('userPreferences');
10
11 // If there is no cookie and this is the first HTML rendering,
12 // add a script detecting system preferences
13 if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
14 const url = request.nextUrl.clone();
15
16 // If this is an HTML page request and not API or static assets
17 if (!url.pathname.startsWith('/api/') && !url.pathname.match(/.(js|css|png|jpg|svg)$/)) {
18 const response = NextResponse.next();
19
20 // In a real implementation you can add a script detecting preferences
21 // and setting appropriate cookies before the first rendering
22
23 return response;
24 }
25 }
26
27 return response;
28}
29
30export const config = {
31 matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
32};1// hooks/useDayNightMode.ts
2import { useState, useEffect } from 'react';
3
4export function useDayNightMode() {
5 const [isDarkMode, setIsDarkMode] = useState(false);
6
7 useEffect(() => {
8 // Function checking if it is currently night (from 18:00 to 6:00)
9 const checkIfNight = () => {
10 const hours = new Date().getHours();
11 return hours >= 18 || hours < 6;
12 };
13
14 // Initial setting
15 setIsDarkMode(checkIfNight());
16
17 // Interval checking time every minute
18 const interval = setInterval(() => {
19 setIsDarkMode(checkIfNight());
20 }, 60000);
21
22 return () => clearInterval(interval);
23 }, []);
24
25 return isDarkMode;
26}1// components/AutomaticThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { useDayNightMode } from '@/hooks/useDayNightMode';
6import styles from './AutomaticThemeToggle.module.css';
7
8export function AutomaticThemeToggle() {
9 const [isAutoMode, setIsAutoMode] = useState(false);
10 const isDarkMode = useDayNightMode();
11 const [manualDarkMode, setManualDarkMode] = useState(false);
12
13 // Effect changing mode based on automatic detection or manual setting
14 useEffect(() => {
15 if (isAutoMode) {
16 document.documentElement.classList.toggle('dark-theme', isDarkMode);
17 } else {
18 document.documentElement.classList.toggle('dark-theme', manualDarkMode);
19 }
20 }, [isAutoMode, isDarkMode, manualDarkMode]);
21
22 return (
23 <div className={styles.themeControls}>
24 <div className={styles.autoToggle}>
25 <label>
26 <input
27 type="checkbox"
28 checked={isAutoMode}
29 onChange={(e) => setIsAutoMode(e.target.checked)}
30 />
31 Automatyczny tryb dzienny/nocny
32 </label>
33 </div>
34
35 {!isAutoMode && (
36 <div className={styles.manualToggle}>
37 <button
38 onClick={() => setManualDarkMode(!manualDarkMode)}
39 className={styles.themeButton}
40 >
41 {manualDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
42 </button>
43 </div>
44 )}
45
46 <div className={styles.currentMode}>
47 Aktualny tryb: {(isAutoMode ? isDarkMode : manualDarkMode) ? 'Ciemny' : 'Jasny'}
48 </div>
49 </div>
50 );
51}prefers-color-scheme as the default settingTailwind CSS integrates excellently with dark mode:
1// components/DarkModeExample.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5
6export function DarkModeExample() {
7 const [darkMode, setDarkMode] = useState(false);
8
9 useEffect(() => {
10 // Check system preferences
11 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
12 const savedTheme = localStorage.getItem('theme');
13 const initialDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);
14
15 setDarkMode(initialDarkMode);
16 document.documentElement.classList.toggle('dark', initialDarkMode);
17 }, []);
18
19 const toggleDarkMode = () => {
20 const newDarkMode = !darkMode;
21 setDarkMode(newDarkMode);
22 document.documentElement.classList.toggle('dark', newDarkMode);
23 localStorage.setItem('theme', newDarkMode ? 'dark' : 'light');
24 };
25
26 return (
27 <div className="p-8 bg-white dark:bg-gray-900 min-h-screen transition-colors duration-300">
28 <div className="max-w-4xl mx-auto">
29 <div className="flex justify-between items-center mb-8">
30 <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
31 Metropolis Quantum
32 </h1>
33
34 <button
35 onClick={toggleDarkMode}
36 className="p-2 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-white transition-colors duration-300"
37 >
38 {darkMode ? '☀️' : '🌙'}
39 </button>
40 </div>
41
42 <div className="grid gap-6 md:grid-cols-2">
43 <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
44 <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
45 About the City
46 </h2>
47 <p className="text-gray-700 dark:text-gray-300">
48 Metropolis Quantum is the city of the future where technology and humanity coexist in harmony.
49 </p>
50 </div>
51
52 <div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
53 <h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
54 Services
55 </h2>
56 <ul className="space-y-2 text-gray-700 dark:text-gray-300">
57 <li className="flex items-center">
58 <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
59 <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
60 </svg>
61 Transport kwantowy
62 </li>
63 <li className="flex items-center">
64 <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
65 <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
66 </svg>
67 Holographic Displays
68 </li>
69 <li className="flex items-center">
70 <svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
71 <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" />
72 </svg>
73 Inteligentna infrastruktura
74 </li>
75 </ul>
76 </div>
77 </div>
78
79 <div className="mt-8 bg-blue-600 dark:bg-blue-800 p-6 rounded-lg text-white shadow-lg">
80 <h2 className="text-xl font-semibold mb-4">Join the Community</h2>
81 <p className="mb-4">
82 Discover the amazing possibilities that Metropolis Quantum offers.
83 </p>
84 <button className="px-4 py-2 bg-white text-blue-600 dark:bg-gray-800 dark:text-blue-400 rounded-md font-medium hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-300">
85 Sign Up
86 </button>
87 </div>
88 </div>
89 </div>
90 );
91}Effective implementation of dark mode and managing user preferences in Next.js 15 is an important element of user experience. Just as Quantum Metropolis adapts its systems to the time of day and residents' preferences, modern web applications should respect user choices while also caring about accessibility, performance, and interface consistency.
Key takeaways:
Thanks to modern tools such as CSS variables, React's context API, and libraries like next-themes, implementing dark mode and managing user preferences becomes simpler and more effective. Next.js 15 provides a solid foundation for creating adaptive interfaces that respond to user preferences and deliver optimal experiences regardless of conditions.