Kurs Next.js · Moduł 3: Stylowanie i optymalizacja wizualna
Dark Mode i preferencje użytkownika w Next.js 16
W tej lekcji9
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 16.
Znaczenie Dark Mode i preferencji użytkownika
Dlaczego Dark Mode jest ważny?
- Komfort użytkowania - redukuje zmęczenie oczu, szczególnie w warunkach słabego oświetlenia
- Oszczędność energii - na urządzeniach z ekranami OLED/AMOLED tryb ciemny może znacząco wydłużyć czas pracy baterii
- Dostępność - pomaga użytkownikom z wrażliwością na światło lub określonymi wadami wzroku
- Estetyka - wiele osób preferuje ciemne interfejsy ze względów estetycznych
- Zgodność z systemem - utrzymuje spójność z preferencjami systemu operacyjnego użytkownika
Inne preferencje użytkownika
Oprócz trybu ciemnego, warto uwzględnić również inne preferencje:
- Rozmiar tekstu - dla poprawy czytelności
- Kontrast - niektórzy użytkownicy potrzebują wyższego kontrastu
- Animacje - opcja redukcji animacji dla osób z zaburzeniami przedsionkowymi
- Preferowany język - wsparcie dla wielu języków
- Układ - opcje zmiany układu (np. gęstość zawartości)
Implementacja Dark Mode w Next.js 16
Next.js 16 oferuje kilka podejść do implementacji trybu ciemnego:
1. CSS i zmienne CSS
Najprostsze podejście wykorzystuje zmienne CSS i selektory klas:
/* app/globals.css */
:root {
/* Zmienne dla trybu jasnego */
--background: #ffffff;
--foreground: #121212;
--primary: #0070f3;
--secondary: #6b7280;
--accent: #8b5cf6;
--muted: #f3f4f6;
--border: #e5e7eb;
}
/* Klasa dla trybu ciemnego */
.dark-theme {
--background: #121212;
--foreground: #f3f4f6;
--primary: #3b82f6;
--secondary: #9ca3af;
--accent: #a78bfa;
--muted: #1f2937;
--border: #374151;
}
body {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--muted);
border: 1px solid var(--border);
color: var(--foreground);
}
.button-primary {
background-color: var(--primary);
color: white;
}2. Komponent do przełączania trybu
// components/ThemeToggle.tsx
'use client';
import { useState, useEffect } from 'react';
import { MoonIcon, SunIcon } from '@/components/Icons';
import styles from './ThemeToggle.module.css';
export function ThemeToggle() {
// Rozpocznij z neutralną wartością, aby uniknąć mignięcia przy hydratacji
const [isDarkMode, setIsDarkMode] = useState<boolean | undefined>(undefined);
useEffect(() => {
// Sprawdź preferencje systemowe
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Sprawdź zapisane preferencje użytkownika
const savedTheme = localStorage.getItem('theme');
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setIsDarkMode(initialTheme === 'dark');
document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
}, []);
// Efekt uruchamiany przy zmianie stanu
useEffect(() => {
if (isDarkMode === undefined) return;
document.documentElement.classList.toggle('dark-theme', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Nie renderuj przycisku przed określeniem początkowego stanu
if (isDarkMode === undefined) return null;
return (
<button
className={styles.themeToggle}
onClick={() => setIsDarkMode(!isDarkMode)}
aria-label={isDarkMode ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
>
{isDarkMode ? <SunIcon /> : <MoonIcon />}
</button>
);
}/* components/ThemeToggle.module.css */
.themeToggle {
background: none;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--foreground);
transition: background-color 0.3s ease;
}
.themeToggle:hover {
background-color: var(--muted);
}3. Provider kontekstu dla globalnego dostępu do tematu
// contexts/ThemeContext.tsx
'use client';
import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
// Inicjalizacja tematu na podstawie preferencji systemowych i zapisanych ustawień
useEffect(() => {
// Sprawdź czy jesteśmy w środowisku przeglądarki
if (typeof window !== 'undefined') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme') as Theme | null;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(initialTheme);
document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
}
}, []);
// Efekt uruchamiany przy zmianie tematu
useEffect(() => {
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark-theme', theme === 'dark');
localStorage.setItem('theme', theme);
}
}, [theme]);
// Funkcja do przełączania tematu
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Hook do używania kontekstu tematu
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}4. Implementacja w root layout
// app/layout.tsx
import { ThemeProvider } from '@/contexts/ThemeContext';
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Metropolis Quantum',
description: 'Miasto przyszłości w zasięgu ręki',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pl">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}5. Użycie kontekstu w komponentach
// components/Header.tsx
'use client';
import { useTheme } from '@/contexts/ThemeContext';
import Link from 'next/link';
import styles from './Header.module.css';
export function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={styles.header}>
<div className={styles.logo}>
<Link href="/">Metropolis Quantum</Link>
</div>
<nav className={styles.nav}>
<ul>
<li><Link href="/about">O nas</Link></li>
<li><Link href="/services">Usługi</Link></li>
<li><Link href="/contact">Kontakt</Link></li>
</ul>
</nav>
<button
className={styles.themeToggle}
onClick={toggleTheme}
aria-label={theme === 'dark' ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
>
{theme === 'dark' ? 'Jasny' : 'Ciemny'}
</button>
</header>
);
}Zaawansowane implementacje Dark Mode
1. Użycie next-themes
next-themes to popularna biblioteka, która upraszcza implementację trybu ciemnego:
npm install next-themes// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Metropolis Quantum',
description: 'Miasto przyszłości w zasięgu ręki',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pl" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}/* app/globals.css */
:root {
--background: #ffffff;
--foreground: #121212;
/* Inne zmienne dla jasnego motywu */
}
.dark {
--background: #121212;
--foreground: #f3f4f6;
/* Inne zmienne dla ciemnego motywu */
}
body {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s ease, color 0.3s ease;
}// components/ThemeSwitch.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeSwitch() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// Efekt uruchamiany po montowaniu komponentu
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null; // Nie renderuj podczas pierwszego renderowania (SSR)
}
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="bg-transparent px-4 py-2 rounded border border-gray-300 dark:border-gray-700"
>
<option value="system">Ustawienia systemowe</option>
<option value="light">Jasny</option>
<option value="dark">Ciemny</option>
</select>
);
}2. Wsparcie dla wielu motywów (nie tylko jasny/ciemny)
// contexts/ThemeContext.tsx
'use client';
import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'sepia' | 'forest' | 'ocean';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
// Inicjalizacja tematu
useEffect(() => {
if (typeof window !== 'undefined') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme') as Theme | null;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(initialTheme);
// Usuń wszystkie klasy motywów
document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
// Dodaj aktualną klasę motywu
document.documentElement.classList.add(`theme-${initialTheme}`);
}
}, []);
// Efekt uruchamiany przy zmianie tematu
useEffect(() => {
if (typeof window !== 'undefined') {
// Usuń wszystkie klasy motywów
document.documentElement.classList.remove('theme-light', 'theme-dark', 'theme-sepia', 'theme-forest', 'theme-ocean');
// Dodaj aktualną klasę motywu
document.documentElement.classList.add(`theme-${theme}`);
localStorage.setItem('theme', theme);
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}/* app/globals.css */
:root {
/* Podstawowe zmienne */
}
/* Jasny motyw (domyślny) */
.theme-light {
--background: #ffffff;
--foreground: #121212;
--primary: #0070f3;
--secondary: #6b7280;
--accent: #8b5cf6;
--muted: #f3f4f6;
--border: #e5e7eb;
}
/* Ciemny motyw */
.theme-dark {
--background: #121212;
--foreground: #f3f4f6;
--primary: #3b82f6;
--secondary: #9ca3af;
--accent: #a78bfa;
--muted: #1f2937;
--border: #374151;
}
/* Motyw sepia (przyjazny dla oczu) */
.theme-sepia {
--background: #f9f5eb;
--foreground: #433422;
--primary: #9c6644;
--secondary: #7d6e5d;
--accent: #c27b57;
--muted: #efe8d9;
--border: #d3c6ad;
}
/* Motyw leśny */
.theme-forest {
--background: #1e2a20;
--foreground: #e9f0db;
--primary: #52b788;
--secondary: #74c69d;
--accent: #95d5b2;
--muted: #2d3a2e;
--border: #40534e;
}
/* Motyw oceaniczny */
.theme-ocean {
--background: #0c2333;
--foreground: #e0f2fe;
--primary: #0ea5e9;
--secondary: #38bdf8;
--accent: #7dd3fc;
--muted: #164e63;
--border: #0369a1;
}
body {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s ease, color 0.3s ease;
}3. Animowane przejścia między motywami
/* app/globals.css */
body {
background-color: var(--background);
color: var(--foreground);
transition:
background-color 0.5s ease,
color 0.5s ease,
border-color 0.5s ease,
box-shadow 0.5s ease;
}
/* Animacja dla elementów zagnieżdżonych */
* {
transition:
background-color 0.5s ease,
color 0.5s ease,
border-color 0.5s ease,
fill 0.5s ease,
stroke 0.5s ease;
}4. Zastosowanie tailwindcss dla trybu ciemnego
Tailwind CSS ma wbudowane wsparcie dla trybu ciemnego:
// components/Card.tsx
export function Card({ title, content }) {
return (
<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">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{title}</h3>
<p className="text-gray-700 dark:text-gray-300">{content}</p>
</div>
);
}Zapisywanie i zarządzanie preferencjami użytkownika
1. Używanie localStorage
// utils/userPreferences.ts
export interface UserPreferences {
theme: 'light' | 'dark' | 'system';
fontSize: 'small' | 'medium' | 'large';
reducedMotion: boolean;
highContrast: boolean;
language: string;
}
const defaultPreferences: UserPreferences = {
theme: 'system',
fontSize: 'medium',
reducedMotion: false,
highContrast: false,
language: 'pl',
};
export function getUserPreferences(): UserPreferences {
if (typeof window === 'undefined') {
return defaultPreferences;
}
try {
const savedPreferences = localStorage.getItem('userPreferences');
return savedPreferences
? { ...defaultPreferences, ...JSON.parse(savedPreferences) }
: defaultPreferences;
} catch (error) {
console.error('Error reading user preferences:', error);
return defaultPreferences;
}
}
export function saveUserPreferences(preferences: Partial<UserPreferences>): void {
if (typeof window === 'undefined') {
return;
}
try {
const currentPreferences = getUserPreferences();
const updatedPreferences = { ...currentPreferences, ...preferences };
localStorage.setItem('userPreferences', JSON.stringify(updatedPreferences));
} catch (error) {
console.error('Error saving user preferences:', error);
}
}2. Preferencje użytkownika z pomocą Cookies
Cookies mogą być używane do przechowywania preferencji między sesjami i są dostępne zarówno na kliencie, jak i na serwerze:
// lib/cookies.ts
import { parseCookies, setCookie } from 'nookies';
import { UserPreferences, defaultPreferences } from './userPreferences';
export function getUserPreferencesFromCookies(ctx?: any): UserPreferences {
const cookies = parseCookies(ctx);
if (!cookies.userPreferences) {
return defaultPreferences;
}
try {
return { ...defaultPreferences, ...JSON.parse(cookies.userPreferences) };
} catch (error) {
console.error('Error parsing user preferences from cookies:', error);
return defaultPreferences;
}
}
export function saveUserPreferencesToCookies(
preferences: Partial<UserPreferences>,
ctx?: any
): void {
const currentPreferences = getUserPreferencesFromCookies(ctx);
const updatedPreferences = { ...currentPreferences, ...preferences };
setCookie(ctx, 'userPreferences', JSON.stringify(updatedPreferences), {
maxAge: 365 * 24 * 60 * 60, // 1 rok
path: '/',
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
});
}3. Przełączniki dla różnych preferencji
// components/UserPreferencesPanel.tsx
'use client';
import { useState, useEffect } from 'react';
import { UserPreferences, getUserPreferences, saveUserPreferences } from '@/utils/userPreferences';
import styles from './UserPreferencesPanel.module.css';
export function UserPreferencesPanel() {
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setPreferences(getUserPreferences());
}, []);
if (!preferences) return null;
const updatePreference = <K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => {
const updatedPreferences = { ...preferences, [key]: value };
setPreferences(updatedPreferences);
saveUserPreferences({ [key]: value });
// Zastosuj preferencje natychmiast
applyPreferences(key, value);
};
const applyPreferences = <K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => {
switch (key) {
case 'theme':
const theme = value as 'light' | 'dark' | 'system';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const effectiveTheme = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
document.documentElement.classList.remove('theme-light', 'theme-dark');
document.documentElement.classList.add(`theme-${effectiveTheme}`);
break;
case 'fontSize':
document.documentElement.style.fontSize = {
small: '14px',
medium: '16px',
large: '18px',
}[value as string] || '16px';
break;
case 'reducedMotion':
document.documentElement.classList.toggle('reduce-motion', value as boolean);
break;
case 'highContrast':
document.documentElement.classList.toggle('high-contrast', value as boolean);
break;
case 'language':
// W rzeczywistej aplikacji - tutaj zaimplementowałbyś zmianę języka
break;
}
};
return (
<>
<button
className={styles.settingsButton}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
Preferencje
</button>
{isOpen && (
<div className={styles.panel}>
<h2>Preferencje użytkownika</h2>
<div className={styles.preferenceGroup}>
<h3>Motyw</h3>
<div className={styles.options}>
<label>
<input
type="radio"
name="theme"
value="light"
checked={preferences.theme === 'light'}
onChange={() => updatePreference('theme', 'light')}
/>
Jasny
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
checked={preferences.theme === 'dark'}
onChange={() => updatePreference('theme', 'dark')}
/>
Ciemny
</label>
<label>
<input
type="radio"
name="theme"
value="system"
checked={preferences.theme === 'system'}
onChange={() => updatePreference('theme', 'system')}
/>
Zgodny z systemem
</label>
</div>
</div>
<div className={styles.preferenceGroup}>
<h3>Rozmiar tekstu</h3>
<div className={styles.options}>
<label>
<input
type="radio"
name="fontSize"
value="small"
checked={preferences.fontSize === 'small'}
onChange={() => updatePreference('fontSize', 'small')}
/>
Mały
</label>
<label>
<input
type="radio"
name="fontSize"
value="medium"
checked={preferences.fontSize === 'medium'}
onChange={() => updatePreference('fontSize', 'medium')}
/>
Średni
</label>
<label>
<input
type="radio"
name="fontSize"
value="large"
checked={preferences.fontSize === 'large'}
onChange={() => updatePreference('fontSize', 'large')}
/>
Duży
</label>
</div>
</div>
<div className={styles.preferenceGroup}>
<h3>Dostępność</h3>
<div className={styles.options}>
<label>
<input
type="checkbox"
checked={preferences.reducedMotion}
onChange={(e) => updatePreference('reducedMotion', e.target.checked)}
/>
Zredukowane animacje
</label>
<label>
<input
type="checkbox"
checked={preferences.highContrast}
onChange={(e) => updatePreference('highContrast', e.target.checked)}
/>
Wysoki kontrast
</label>
</div>
</div>
<div className={styles.preferenceGroup}>
<h3>Język</h3>
<select
value={preferences.language}
onChange={(e) => updatePreference('language', e.target.value)}
className={styles.select}
>
<option value="pl">Polski</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
</div>
<button
className={styles.closeButton}
onClick={() => setIsOpen(false)}
>
Zamknij
</button>
</div>
)}
</>
);
}CSS dla różnych preferencji
1. Zmienne CSS dla różnych preferencji
/* app/globals.css */
:root {
/* Podstawowe zmienne */
--background: #ffffff;
--foreground: #121212;
/* ... (inne podstawowe kolory) ... */
/* Zmienne dla rozmiaru tekstu */
--font-size-base: 16px;
--font-size-small: 0.875rem;
--font-size-medium: 1rem;
--font-size-large: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
/* Zmienne dla odstępów */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Zmienne dla animacji */
--transition-speed: 0.3s;
}
/* Tryb ciemny */
.theme-dark {
--background: #121212;
--foreground: #f3f4f6;
/* ... (inne kolory dla trybu ciemnego) ... */
}
/* Wysoki kontrast */
.high-contrast {
--background: #000000;
--foreground: #ffffff;
--primary: #ffff00;
--secondary: #00ffff;
--accent: #ff00ff;
--muted: #333333;
--border: #ffffff;
}
/* Duży tekst */
html[data-text-size="large"] {
--font-size-base: 18px;
--font-size-small: 1rem;
--font-size-medium: 1.125rem;
--font-size-large: 1.375rem;
--font-size-xl: 1.75rem;
--font-size-2xl: 2.25rem;
}
/* Zredukowane animacje */
.reduce-motion * {
animation-duration: 0.001s !important;
transition-duration: 0.001s !important;
scroll-behavior: auto !important;
}
/* Podstawowe style */
html {
font-size: var(--font-size-base);
}
body {
background-color: var(--background);
color: var(--foreground);
font-size: var(--font-size-medium);
transition:
background-color var(--transition-speed) ease,
color var(--transition-speed) ease;
}
a {
color: var(--primary);
transition: color var(--transition-speed) ease;
}
.reduce-motion a {
transition: none;
}2. Wysokość komponentów dla różnych rozmiarów tekstu
/* components/Button.module.css */
.button {
background-color: var(--primary);
color: white;
border-radius: 0.375rem;
font-weight: 500;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-medium);
height: calc(var(--font-size-medium) * 2.5);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-speed) ease;
}
.button:hover {
background-color: var(--primary-dark);
}
.reduce-motion .button {
transition: none;
}Implementacja dla zaawansowanych przypadków
1. Tryb ciemny z przechowywaniem na serwerze
Dla aplikacji z uwierzytelnianiem, preferencje użytkownika mogą być przechowywane na serwerze:
// app/api/preferences/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const preferences = await prisma.userPreferences.findUnique({
where: { userId: session.user.id },
});
return NextResponse.json(preferences);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch preferences' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const data = await req.json();
const preferences = await prisma.userPreferences.upsert({
where: { userId: session.user.id },
update: data,
create: {
userId: session.user.id,
...data,
},
});
return NextResponse.json(preferences);
} catch (error) {
return NextResponse.json({ error: 'Failed to update preferences' }, { status: 500 });
}
}2. SSR i prerenderowanie z obsługą preferencji
// proxy.ts (do Next.js 15: middleware.ts z funkcją middleware)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const response = NextResponse.next();
// Jeśli to pierwsze żądanie, sprawdź czy istnieje cookie z preferencjami
const hasPrefsCookie = request.cookies.has('userPreferences');
// Jeśli nie ma cookie a jest to pierwsze renderowanie HTML,
// dodaj skrypt wykrywający preferencje systemowe
if (!hasPrefsCookie && request.headers.get('sec-fetch-dest') === 'document') {
const url = request.nextUrl.clone();
// Jeśli jest to żądanie strony HTML a nie API lub statycznych zasobów
if (!url.pathname.startsWith('/api/') && !url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
const response = NextResponse.next();
// W rzeczywistej implementacji możesz dodać skrypt wykrywający preferencje
// i ustawiający odpowiednie cookie przed pierwszym renderowaniem
return response;
}
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};3. Automatyczne przełączanie trybu ciemnego w zależności od pory dnia
// hooks/useDayNightMode.ts
import { useState, useEffect } from 'react';
export function useDayNightMode() {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
// Funkcja sprawdzająca czy obecnie jest noc (od 18:00 do 6:00)
const checkIfNight = () => {
const hours = new Date().getHours();
return hours >= 18 || hours < 6;
};
// Początkowe ustawienie
setIsDarkMode(checkIfNight());
// Interwał sprawdzający czas co minutę
const interval = setInterval(() => {
setIsDarkMode(checkIfNight());
}, 60000);
return () => clearInterval(interval);
}, []);
return isDarkMode;
}// components/AutomaticThemeToggle.tsx
'use client';
import { useState, useEffect } from 'react';
import { useDayNightMode } from '@/hooks/useDayNightMode';
import styles from './AutomaticThemeToggle.module.css';
export function AutomaticThemeToggle() {
const [isAutoMode, setIsAutoMode] = useState(false);
const isDarkMode = useDayNightMode();
const [manualDarkMode, setManualDarkMode] = useState(false);
// Efekt zmieniający tryb na podstawie automatycznego wykrywania lub ręcznego ustawienia
useEffect(() => {
if (isAutoMode) {
document.documentElement.classList.toggle('dark-theme', isDarkMode);
} else {
document.documentElement.classList.toggle('dark-theme', manualDarkMode);
}
}, [isAutoMode, isDarkMode, manualDarkMode]);
return (
<div className={styles.themeControls}>
<div className={styles.autoToggle}>
<label>
<input
type="checkbox"
checked={isAutoMode}
onChange={(e) => setIsAutoMode(e.target.checked)}
/>
Automatyczny tryb dzienny/nocny
</label>
</div>
{!isAutoMode && (
<div className={styles.manualToggle}>
<button
onClick={() => setManualDarkMode(!manualDarkMode)}
className={styles.themeButton}
>
{manualDarkMode ? 'Przełącz na tryb jasny' : 'Przełącz na tryb ciemny'}
</button>
</div>
)}
<div className={styles.currentMode}>
Aktualny tryb: {(isAutoMode ? isDarkMode : manualDarkMode) ? 'Ciemny' : 'Jasny'}
</div>
</div>
);
}Najlepsze praktyki
- Respektuj preferencje systemowe - zawsze sprawdzaj
prefers-color-schemejako domyślne ustawienie - Unikaj mignięć (flash) - zapobiegaj miganiu zawartości podczas przełączania trybów
- Konsekwentne kolory - upewnij się, że wszystkie elementy interfejsu poprawnie reagują na zmianę trybu
- Testuj dokładnie - sprawdź, jak wygląda aplikacja w obu trybach
- Dostępność - pamiętaj, że tryb ciemny to nie tylko kwestia wyglądu, ale również dostępności
- Wydajność - unikaj wielokrotnego przepisywania klas i stylów podczas przełączania
- Sygnalizuj aktywny tryb - jasno oznaczaj, który tryb jest obecnie aktywny
- Zapisuj preferencje - zapisuj wybrane preferencje użytkownika między sesjami
Implementacja z TailwindCSS
Tailwind CSS doskonale integruje się z trybem ciemnym:
// components/DarkModeExample.tsx
'use client';
import { useState, useEffect } from 'react';
export function DarkModeExample() {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
// Sprawdź preferencje systemowe
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
const initialDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);
setDarkMode(initialDarkMode);
document.documentElement.classList.toggle('dark', initialDarkMode);
}, []);
const toggleDarkMode = () => {
const newDarkMode = !darkMode;
setDarkMode(newDarkMode);
document.documentElement.classList.toggle('dark', newDarkMode);
localStorage.setItem('theme', newDarkMode ? 'dark' : 'light');
};
return (
<div className="p-8 bg-white dark:bg-gray-900 min-h-screen transition-colors duration-300">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Metropolis Quantum
</h1>
<button
onClick={toggleDarkMode}
className="p-2 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-white transition-colors duration-300"
>
{darkMode ? 'Jasny' : 'Ciemny'}
</button>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
O mieście
</h2>
<p className="text-gray-700 dark:text-gray-300">
Metropolis Quantum to miasto przyszłości, gdzie technologia i człowiek współistnieją w harmonii.
</p>
</div>
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-800/30">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">
Usługi
</h2>
<ul className="space-y-2 text-gray-700 dark:text-gray-300">
<li className="flex items-center">
<svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
Transport kwantowy
</li>
<li className="flex items-center">
<svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
Holograficzne wyświetlacze
</li>
<li className="flex items-center">
<svg className="w-5 h-5 mr-2 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
Inteligentna infrastruktura
</li>
</ul>
</div>
</div>
<div className="mt-8 bg-blue-600 dark:bg-blue-800 p-6 rounded-lg text-white shadow-lg">
<h2 className="text-xl font-semibold mb-4">Dołącz do społeczności</h2>
<p className="mb-4">
Odkryj niesamowite możliwości, jakie oferuje Metropolis Quantum.
</p>
<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">
Zarejestruj się
</button>
</div>
</div>
</div>
);
}Podsumowanie
Efektywna implementacja trybu ciemnego i zarządzanie preferencjami użytkownika w Next.js 16 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:
- Respektuj preferencje systemowe - wielu użytkowników ma już ustawiony preferowany tryb w systemie
- Udostępnij prosty przełącznik - umożliw łatwe przełączanie między trybami
- Zapisuj wybory użytkownika - zapamiętuj preferencje między sesjami
- Projektuj dla obu trybów - uwzględnij specyfikę obu trybów już na etapie projektowania
- Zwracaj uwagę na dostępność - tryb ciemny to nie tylko kwestia estetyki, ale również dostępności
- Integruj z innymi preferencjami - pozwól użytkownikom dostosować również inne aspekty interfejsu
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 16 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.
Kod do tej lekcji: app/page.tsx
// Accessibility & WCAG - Quantum Metropolis
'use client';
export default function AccessibilityDemo() {
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
color: '#ffffff',
padding: '3rem 2rem'
}}>
<a href="#main-content" style={{
position: 'absolute',
left: '-9999px',
zIndex: 999,
padding: '1rem',
background: '#64ffda',
color: '#0f0f23',
textDecoration: 'none',
borderRadius: '0.5rem',
fontWeight: 600
}}>
Skip to main content
</a>
<header role="banner" style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
<h1 style={{
fontSize: '3rem',
textAlign: 'center',
background: 'linear-gradient(45deg, #64ffda, #7c4dff)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
marginBottom: '1rem'
}}>
Accessibility & WCAG
</h1>
<p style={{textAlign: 'center', color: '#b0bec5', fontSize: '1.25rem'}}>
Budowanie dostępnych interfejsów dla wszystkich
</p>
</header>
<main id="main-content" role="main" style={{maxWidth: '1200px', margin: '0 auto'}}>
{/* Semantic HTML */}
<section aria-labelledby="semantic-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="semantic-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>1. Semantic HTML</h2>
<div style={{color: '#b0bec5', lineHeight: 1.8}}>
<p style={{marginBottom: '1rem'}}>Używaj semantycznych znaczników HTML zamiast generycznych <div>:</p>
<ul style={{paddingLeft: '2rem', marginBottom: '1.5rem'}}>
<li><header>, <nav>, <main>, <article>, <section>, <aside>, <footer></li>
<li><button> zamiast <div onclick></li>
<li><h1> do <h6> dla hierarchii nagłówków</li>
</ul>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`<article aria-labelledby="article-title">
<h2 id="article-title">Tytuł artykułu</h2>
<p>Treść artykułu...</p>
</article>`}
</pre>
</div>
</section>
{/* ARIA Attributes */}
<section aria-labelledby="aria-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="aria-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>2. ARIA Attributes</h2>
<div style={{marginBottom: '2rem'}}>
<h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Przykład: Przycisk</h3>
<button
aria-label="Zamknij okno dialogowe"
style={{
padding: '0.75rem 2rem',
background: 'linear-gradient(135deg, #64ffda, #448aff)',
border: 'none',
borderRadius: '0.75rem',
color: '#0f0f23',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onFocus={e => e.currentTarget.style.outline = '3px solid #64ffda'}
onBlur={e => e.currentTarget.style.outline = 'none'}
>
Close
</button>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
{`<button aria-label="Zamknij okno dialogowe">
Close
</button>`}
</pre>
</div>
<div>
<h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Przykład: Progress Bar</h3>
<div
role="progressbar"
aria-valuenow={75}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Loading progress"
style={{width: '100%', height: '12px', background: 'rgba(255,255,255,0.1)', borderRadius: '6px', overflow: 'hidden'}}
>
<div style={{width: '75%', height: '100%', background: 'linear-gradient(90deg, #64ffda, #7c4dff)', borderRadius: '6px', transition: 'width 0.5s ease'}}></div>
</div>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1rem'}}>
{`<div
role="progressbar"
aria-valuenow="75"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Loading progress"
>
<div style="width: 75%"></div>
</div>`}
</pre>
</div>
</section>
{/* Focus Management */}
<section aria-labelledby="focus-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="focus-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>3. Focus Styles</h2>
<p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
Zawsze zapewniaj widoczne focus states dla użytkowników nawigujących klawiaturą
</p>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem'}}>
<button style={{
padding: '1rem',
background: 'rgba(100,255,218,0.2)',
border: '2px solid #64ffda',
borderRadius: '0.75rem',
color: '#64ffda',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onFocus={e => {
e.currentTarget.style.outline = '3px solid #64ffda';
e.currentTarget.style.outlineOffset = '2px';
}}
onBlur={e => e.currentTarget.style.outline = 'none'}
>
Button 1
</button>
<button style={{
padding: '1rem',
background: 'rgba(124,77,255,0.2)',
border: '2px solid #7c4dff',
borderRadius: '0.75rem',
color: '#7c4dff',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onFocus={e => {
e.currentTarget.style.outline = '3px solid #7c4dff';
e.currentTarget.style.outlineOffset = '2px';
}}
onBlur={e => e.currentTarget.style.outline = 'none'}
>
Button 2
</button>
<button style={{
padding: '1rem',
background: 'rgba(255,0,128,0.2)',
border: '2px solid #ff0080',
borderRadius: '0.75rem',
color: '#ff0080',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onFocus={e => {
e.currentTarget.style.outline = '3px solid #ff0080';
e.currentTarget.style.outlineOffset = '2px';
}}
onBlur={e => e.currentTarget.style.outline = 'none'}
>
Button 3
</button>
</div>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem', marginTop: '1.5rem'}}>
{`button:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}`}
</pre>
</section>
{/* Color Contrast */}
<section aria-labelledby="contrast-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="contrast-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>4. Color Contrast (WCAG AA)</h2>
<p style={{color: '#b0bec5', marginBottom: '1.5rem', lineHeight: 1.6}}>
Minimalne współczynniki kontrastu według WCAG:
</p>
<ul style={{color: '#b0bec5', lineHeight: 2, paddingLeft: '2rem', marginBottom: '1.5rem'}}>
<li>Normal text: 4.5:1</li>
<li>Large text (18pt+ lub 14pt+ bold): 3:1</li>
<li>UI components & graphics: 3:1</li>
</ul>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.5rem'}}>
<div style={{background: '#ffffff', padding: '1.5rem', borderRadius: '0.75rem'}}>
<p style={{color: '#212529', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
<p style={{color: '#212529', fontSize: '0.875rem'}}>Black text on white background (21:1)</p>
</div>
<div style={{background: '#64ffda', padding: '1.5rem', borderRadius: '0.75rem'}}>
<p style={{color: '#0f0f23', fontWeight: 600, marginBottom: '0.5rem'}}>Good Contrast</p>
<p style={{color: '#0f0f23', fontSize: '0.875rem'}}>Dark text on cyan background (9.2:1)</p>
</div>
</div>
</section>
{/* Screen Reader Support */}
<section aria-labelledby="sr-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="sr-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>5. Screen Reader Support</h2>
<div style={{marginBottom: '2rem'}}>
<h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Wizualnie ukryte, ale dostępne dla SR</h3>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}`}
</pre>
</div>
<div>
<h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Live Regions</h3>
<pre style={{background: 'rgba(0,0,0,0.5)', padding: '1rem', borderRadius: '0.5rem', overflow: 'auto', fontSize: '0.875rem'}}>
{`<div
aria-live="polite"
aria-atomic="true"
role="status"
>
Dane zostały zapisane
</div>`}
</pre>
</div>
</section>
{/* Best Practices Checklist */}
<section aria-labelledby="checklist-heading" style={{marginBottom: '3rem', background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '1rem', border: '1px solid rgba(100,255,218,0.2)'}}>
<h2 id="checklist-heading" style={{color: '#64ffda', marginBottom: '1.5rem'}}>Accessibility Checklist</h2>
<ul style={{color: '#b0bec5', lineHeight: 2.2, paddingLeft: '2rem', listStyle: 'none'}}>
<li>Używaj semantycznego HTML</li>
<li>Zapewnij odpowiednie ARIA labels</li>
<li>Wszystkie interaktywne elementy są dostępne z klawiatury</li>
<li>Focus states są wyraźnie widoczne</li>
<li>Kontrast kolorów spełnia WCAG AA (4.5:1)</li>
<li>Obrazy mają alt text</li>
<li>Formularze mają labels i error messages</li>
<li>Aplikacja działa bez JavaScript</li>
<li>Testowane z screen readerami</li>
<li>Responsive i działa na różnych rozmiarach ekranu</li>
</ul>
</section>
</main>
<footer role="contentinfo" style={{maxWidth: '1200px', margin: '3rem auto 0', textAlign: 'center', padding: '2rem', borderTop: '1px solid rgba(100,255,218,0.2)', color: '#78909c'}}>
<p>© 2150 Quantum Metropolis • Accessibility First</p>
</footer>
</div>
);
}Hasła z dokumentacji w tej lekcji
Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Którego wyrażenia użyto w lekcji do wykrycia systemowej preferencji ciemnego motywu?
2. Gdzie w lekcji zapisywano wybrany przez użytkownika motyw, aby przetrwał odświeżenie strony?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki implementacji Dark Mode opisane w lekcji w prawidłowej kolejności: