W Metropolis Quantum, miasto dostosowuje się do potrzeb swoich mieszkańców. W dzień, szklane fasady budynków przepuszczają naturalne światło słoneczne, a holograficzne wyświetlacze działają w trybie o wysokiej jasności. Gdy zapada zmrok, miasto automatycznie przełącza się w tryb nocny - oświetlenie staje się cieplejsze i mniej intensywne, holograficzne wyświetlacze przechodzą w tryb ciemny, by nie męczyć oczu mieszkańców.
W nowoczesnych aplikacjach webowych, podobny mechanizm - Dark Mode (tryb ciemny) - stał się nie tylko elementem estetyki, ale standardem użyteczności i dostępności. W tym rozdziale poznamy sposoby implementacji trybu ciemnego i zarządzania preferencjami użytkowników w aplikacjach Next.js 15.
Oprócz trybu ciemnego, warto uwzględnić również inne preferencje:
Next.js 15 oferuje kilka podejść do implementacji trybu ciemnego:
Najprostsze podejście wykorzystuje zmienne CSS i selektory klas:
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 // Rozpocznij z neutralną wartością, aby uniknąć mignięcia przy hydratacji
10 const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);
11
12 useEffect(() => {
13 // Sprawdź preferencje systemowe
14 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
15
16 // Sprawdź zapisane preferencje użytkownika
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 // Nie renderuj przycisku przed określeniem początkowego stanu
33 if (isDarkMode === undefined) return null;
34
35 return (
36 <button
37 className={styles.themeToggle}
38 onClick={() => setIsDarkMode(!isDarkMode)}
39 aria-label={isDarkMode ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
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 // Inicjalizacja tematu na podstawie preferencji systemowych i zapisanych ustawień
20 useEffect(() => {
21 // Sprawdź czy jesteśmy w środowisku przeglądarki
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 // Funkcja do przełączania tematu
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 do używania kontekstu tematu
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: 'Miasto przyszłości w zasięgu ręki',
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">Usługi</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' ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
27 >
28 {theme === 'dark' ? '☀️' : '🌙'}
29 </button>
30 </header>
31 );
32}next-themes to popularna biblioteka, która upraszcza implementację trybu ciemnego: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: 'Miasto przyszłości w zasięgu ręki',
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 // Usuń wszystkie klasy motywów
28 document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
29 // Dodaj aktualną klasę motywu
30 document.documentElement.classList.add(`theme-${initialTheme}`);
31 }
32 }, []);
33
34 // Efekt uruchamiany przy zmianie tematu
35 useEffect(() => {
36 if (typeof window !== 'undefined') {
37 // Usuń wszystkie klasy motywów
38 document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
39 // Dodaj aktualną klasę motywu
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/* Jasny motyw (domyślny) */
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/* Motyw leśny */
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/* Animacja dla elementów zagnieżdżonych */
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 mogą być używane do przechowywania preferencji między sesjami i są dostępne zarówno na kliencie, jak i na serwerze:
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 // W rzeczywistej aplikacji - tutaj zaimplementowałbyś zmianę języka
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>Preferencje użytkownika</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 Mały
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 Średni
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 Duży
152 </label>
153 </div>
154 </div>
155
156 <div className={styles.preferenceGroup}>
157 <h3>Dostępność</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>Język</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 /* Zmienne dla odstępów */
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/* Duży tekst */
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}Dla aplikacji z uwierzytelnianiem, preferencje użytkownika mogą być przechowywane na serwerze:
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 // Jeśli to pierwsze żądanie, sprawdź czy istnieje cookie z preferencjami
9 const hasPrefsCookie = request.cookies.has('userPreferences');
10
11 // Jeśli nie ma cookie a jest to pierwsze renderowanie HTML,
12 // dodaj skrypt wykrywający preferencje systemowe
13 if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
14 const url = request.nextUrl.clone();
15
16 // Jeśli jest to żądanie strony HTML a nie API lub statycznych zasobów
17 if (!url.pathname.startsWith('/api/') && !url.pathname.match(/.(js|css|png|jpg|svg)$/)) {
18 const response = NextResponse.next();
19
20 // W rzeczywistej implementacji możesz dodać skrypt wykrywający preferencje
21 // i ustawiający odpowiednie cookie przed pierwszym renderowaniem
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 // Funkcja sprawdzająca czy obecnie jest noc (od 18:00 do 6:00)
9 const checkIfNight = () => {
10 const hours = new Date().getHours();
11 return hours >= 18 || hours < 6;
12 };
13
14 // Początkowe ustawienie
15 setIsDarkMode(checkIfNight());
16
17 // Interwał sprawdzający czas co minutę
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 // Efekt zmieniający tryb na podstawie automatycznego wykrywania lub ręcznego ustawienia
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 ? 'Przełącz na tryb jasny' : 'Przełącz na tryb ciemny'}
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 jako domyślne ustawienieTailwind CSS doskonale integruje się z trybem ciemnym:
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 // Sprawdź preferencje systemowe
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 O mieście
46 </h2>
47 <p className="text-gray-700 dark:text-gray-300">
48 Metropolis Quantum to miasto przyszłości, gdzie technologia i człowiek współistnieją w harmonii.
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 Usługi
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 Holograficzne wyświetlacze
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">Dołącz do społeczności</h2>
81 <p className="mb-4">
82 Odkryj niesamowite możliwości, jakie oferuje Metropolis Quantum.
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 Zarejestruj się
86 </button>
87 </div>
88 </div>
89 </div>
90 );
91}Efektywna implementacja trybu ciemnego i zarządzanie preferencjami użytkownika w Next.js 15 stanowią ważny element doświadczenia użytkownika. Podobnie jak Metropolis Quantum dostosowuje swoje systemy do pory dnia i preferencji mieszkańców, nowoczesne aplikacje webowe powinny respektować wybory użytkowników, dbając jednocześnie o dostępność, wydajność i spójność interfejsu.
Najważniejsze punkty:
Dzięki nowoczesnym narzędziom, takim jak CSS variables, context API Reacta czy biblioteki takie jak next-themes, implementacja trybu ciemnego i zarządzanie preferencjami użytkownika stają się prostsze i bardziej efektywne. Next.js 15 zapewnia solidne podstawy do tworzenia adaptacyjnych interfejsów, które reagują na preferencje użytkowników i zapewniają optymalne doświadczenie niezależnie od warunków.