feat(credits): solde IA unique (option M), packs Stripe et polish billing
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.
This commit is contained in:
102
memento-note/components/theme-class-guard.tsx
Normal file
102
memento-note/components/theme-class-guard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'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
|
||||
}
|
||||
Reference in New Issue
Block a user