Skip to content

Commit c5b4c34

Browse files
committed
feat(theme): DarkModeToggle con system pref detection, transizione animata, sync preferences + icona sidebar
2 parents ce15fc9 + b968bf3 commit c5b4c34

2 files changed

Lines changed: 81 additions & 10 deletions

File tree

web/app/components/sidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Link from 'next/link'
44
import { usePathname } from 'next/navigation'
55
import { useState, useEffect } from 'react'
66
import LanguageSwitcher from './LanguageSwitcher'
7+
import { ThemeToggle } from '../theme-provider'
78

89
const NAV_GROUPS = [
910
{
@@ -110,8 +111,9 @@ export default function Sidebar() {
110111
</div>
111112
))}
112113
</nav>
113-
<div className="px-3 py-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
114+
<div className="px-3 py-3 border-t flex items-center justify-between" style={{ borderColor: 'var(--color-border)' }}>
114115
<LanguageSwitcher />
116+
<ThemeToggle />
115117
</div>
116118
</aside>
117119
)

web/app/theme-provider.tsx

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,71 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
55
export type Theme = 'dark' | 'light'
66

77
const STORAGE_KEY = 'jht-theme'
8-
const DEFAULT_THEME: Theme = 'dark'
98

109
type ThemeCtx = { theme: Theme; toggleTheme: () => void; setTheme: (t: Theme) => void }
11-
const ThemeContext = createContext<ThemeCtx>({ theme: DEFAULT_THEME, toggleTheme: () => {}, setTheme: () => {} })
10+
const ThemeContext = createContext<ThemeCtx>({ theme: 'dark', toggleTheme: () => {}, setTheme: () => {} })
1211

1312
export function useTheme() { return useContext(ThemeContext) }
1413

14+
/** Inietta transizione temporanea — evita flash al caricamento ma anima il toggle */
15+
function enableTransition() {
16+
const style = document.createElement('style')
17+
style.id = '__theme-transition'
18+
style.textContent = '*, *::before, *::after { transition: background-color 0.25s ease, border-color 0.25s ease, color 0.15s ease !important; }'
19+
document.head.appendChild(style)
20+
window.setTimeout(() => style.remove(), 300)
21+
}
22+
1523
function applyTheme(theme: Theme) {
1624
document.documentElement.setAttribute('data-theme', theme)
1725
}
1826

27+
/** Rileva preferenza di sistema */
28+
function getSystemTheme(): Theme {
29+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
30+
}
31+
32+
/** Risolve tema iniziale: stored → system → dark */
33+
function resolveInitialTheme(): Theme {
34+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
35+
if (stored === 'dark' || stored === 'light') return stored
36+
return getSystemTheme()
37+
}
38+
1939
export function ThemeProvider({ children }: { children: React.ReactNode }) {
20-
const [theme, setThemeState] = useState<Theme>(DEFAULT_THEME)
40+
const [theme, setThemeState] = useState<Theme>('dark')
2141

42+
// Init: legge localStorage o system preference
2243
useEffect(() => {
23-
const stored = (typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null) as Theme | null
24-
const resolved: Theme = stored === 'light' || stored === 'dark' ? stored : DEFAULT_THEME
44+
const resolved = resolveInitialTheme()
2545
setThemeState(resolved)
2646
applyTheme(resolved)
2747
}, [])
2848

49+
// Ascolta cambi di system preference (solo se nessuna preferenza salvata)
50+
useEffect(() => {
51+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
52+
const handler = (e: MediaQueryListEvent) => {
53+
if (localStorage.getItem(STORAGE_KEY)) return // rispetta scelta utente
54+
const sys: Theme = e.matches ? 'dark' : 'light'
55+
setThemeState(sys)
56+
applyTheme(sys)
57+
}
58+
mq.addEventListener('change', handler)
59+
return () => mq.removeEventListener('change', handler)
60+
}, [])
61+
2962
const setTheme = useCallback((t: Theme) => {
63+
enableTransition()
3064
setThemeState(t)
3165
applyTheme(t)
3266
localStorage.setItem(STORAGE_KEY, t)
67+
// Sync con UserPreferences API (fire-and-forget)
68+
fetch('/api/preferences', {
69+
method: 'PATCH',
70+
headers: { 'Content-Type': 'application/json' },
71+
body: JSON.stringify({ theme: t }),
72+
}).catch(() => null)
3373
}, [])
3474

3575
const toggleTheme = useCallback(() => {
@@ -43,16 +83,45 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
4383
)
4484
}
4585

46-
/** Bottone toggle tema — importabile in qualsiasi header/sidebar */
86+
/** Toggle animato con icone sun/moon */
4787
export function ThemeToggle({ className }: { className?: string }) {
4888
const { theme, toggleTheme } = useTheme()
89+
const isDark = theme === 'dark'
4990
return (
5091
<button
5192
onClick={toggleTheme}
52-
title={theme === 'dark' ? 'Passa a tema chiaro' : 'Passa a tema scuro'}
93+
title={isDark ? 'Passa a tema chiaro' : 'Passa a tema scuro'}
5394
className={className}
54-
style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 14, lineHeight: 1, color: 'var(--color-muted)', padding: '4px 6px', borderRadius: 4 }}>
55-
{theme === 'dark' ? '☀' : '◐'}
95+
style={{
96+
background: 'transparent', border: 'none', cursor: 'pointer',
97+
lineHeight: 1, padding: '4px 6px', borderRadius: 4,
98+
color: isDark ? 'var(--color-yellow)' : 'var(--color-muted)',
99+
fontSize: 15, transition: 'transform 0.3s ease, color 0.2s ease',
100+
}}
101+
onMouseEnter={e => { e.currentTarget.style.transform = 'rotate(20deg) scale(1.15)' }}
102+
onMouseLeave={e => { e.currentTarget.style.transform = 'rotate(0deg) scale(1)' }}
103+
>
104+
{isDark ? '☀' : '◐'}
56105
</button>
57106
)
58107
}
108+
109+
/** DarkModeToggle esteso — mostra testo + icona, usabile in settings */
110+
export function DarkModeToggle() {
111+
const { theme, setTheme } = useTheme()
112+
return (
113+
<div className="flex items-center gap-2">
114+
{(['dark', 'light'] as Theme[]).map(t => (
115+
<button key={t} onClick={() => setTheme(t)}
116+
className="px-3 py-1.5 rounded text-[10px] font-semibold cursor-pointer transition-all"
117+
style={{
118+
border: `1px solid ${theme === t ? 'var(--color-green)' : 'var(--color-border)'}`,
119+
color: theme === t ? 'var(--color-green)' : 'var(--color-dim)',
120+
background: theme === t ? 'rgba(0,232,122,0.08)' : 'transparent',
121+
}}>
122+
{t === 'dark' ? '☀ dark' : '◐ light'}
123+
</button>
124+
))}
125+
</div>
126+
)
127+
}

0 commit comments

Comments
 (0)