Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
103 lines
2.7 KiB
TypeScript
103 lines
2.7 KiB
TypeScript
'use client'
|
|
|
|
import { useLayoutEffect } from 'react'
|
|
import { persistThemePreference, normalizeThemeId } from '@/lib/apply-document-theme'
|
|
|
|
/**
|
|
* Empêche le flash clair en mode sombre.
|
|
*
|
|
* Au changement de route, le layout racine Next peut réécrire la classe de
|
|
* <html> depuis le serveur (souvent « light »). Sans garde, une frame claire
|
|
* apparaît avant le prochain effet React.
|
|
*
|
|
* useLayoutEffect = avant le paint navigateur.
|
|
* MutationObserver = remet le thème si React retire « dark » à tort.
|
|
*/
|
|
export function ThemeClassGuard() {
|
|
useLayoutEffect(() => {
|
|
let applying = false
|
|
|
|
const preferredTheme = (): string => {
|
|
try {
|
|
const local = localStorage.getItem('theme-preference')
|
|
if (local) return normalizeThemeId(local)
|
|
} catch {
|
|
/* localStorage indisponible */
|
|
}
|
|
const server =
|
|
document.documentElement.getAttribute('data-server-theme') || 'light'
|
|
return normalizeThemeId(server)
|
|
}
|
|
|
|
const wantsDarkClass = (theme: string): boolean => {
|
|
const t = normalizeThemeId(theme)
|
|
if (t === 'dark' || t === 'midnight') return true
|
|
if (t === 'auto') {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
}
|
|
return false
|
|
}
|
|
|
|
const sync = () => {
|
|
if (applying) return
|
|
applying = true
|
|
try {
|
|
persistThemePreference(preferredTheme())
|
|
} finally {
|
|
// Laisser le navigateur appliquer le DOM avant de réarmer
|
|
queueMicrotask(() => {
|
|
applying = false
|
|
})
|
|
}
|
|
}
|
|
|
|
// Immédiat (avant paint)
|
|
sync()
|
|
|
|
// Après commit React éventuel qui a écrasé <html>
|
|
const raf1 = requestAnimationFrame(() => {
|
|
sync()
|
|
requestAnimationFrame(sync)
|
|
})
|
|
|
|
const root = document.documentElement
|
|
const observer = new MutationObserver(() => {
|
|
if (applying) return
|
|
const theme = preferredTheme()
|
|
const needDark = wantsDarkClass(theme)
|
|
const hasDark = root.classList.contains('dark')
|
|
if (needDark && !hasDark) {
|
|
sync()
|
|
return
|
|
}
|
|
// data-theme retiré à tort pour une palette nommée
|
|
if (
|
|
theme !== 'light' &&
|
|
theme !== 'dark' &&
|
|
theme !== 'auto' &&
|
|
root.getAttribute('data-theme') !== theme
|
|
) {
|
|
sync()
|
|
}
|
|
})
|
|
|
|
observer.observe(root, {
|
|
attributes: true,
|
|
attributeFilter: ['class', 'data-theme'],
|
|
})
|
|
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key === 'theme-preference') sync()
|
|
}
|
|
window.addEventListener('storage', onStorage)
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf1)
|
|
observer.disconnect()
|
|
window.removeEventListener('storage', onStorage)
|
|
}
|
|
}, [])
|
|
|
|
return null
|
|
}
|