@@ -5,31 +5,71 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
55export type Theme = 'dark' | 'light'
66
77const STORAGE_KEY = 'jht-theme'
8- const DEFAULT_THEME : Theme = 'dark'
98
109type 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
1312export 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+
1523function 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+
1939export 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 */
4787export 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