179 lines
7.2 KiB
TypeScript
179 lines
7.2 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
|
|
import {
|
|
ColorSchemeScript,
|
|
MantineProvider,
|
|
mantineHtmlProps,
|
|
type MantineThemeOverride,
|
|
} from '@pikku/mantine/core'
|
|
import '@mantine/core/styles.css'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { PikkuProvider, createPikku } from '@pikku/react'
|
|
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
|
|
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
|
|
import {
|
|
activeColorScheme,
|
|
activeId,
|
|
activeTheme,
|
|
buildMantineTheme,
|
|
googleFontsHref,
|
|
themeColorSchemes,
|
|
themes,
|
|
type ColorScheme,
|
|
type Theme,
|
|
} from '@project/mantine-themes'
|
|
import { defaultLocale, localeDir, supportedLocales, setActiveLocale } from '@/i18n/config'
|
|
import { apiUrl } from '@/lib/env'
|
|
import { PreferencesContext } from '@/contexts/preferences'
|
|
|
|
const LOCALE_KEY = 'app-locale'
|
|
const THEME_KEY = 'app-theme'
|
|
// The active theme id THEME_KEY was saved against — a preference only holds
|
|
// while that theme is still the workspace's active one, so a builder-side
|
|
// switch (which changes active.json) always wins over a stale local pick.
|
|
const THEME_SAVED_ACTIVE_KEY = 'app-theme-saved-active'
|
|
|
|
const fontsHref = googleFontsHref()
|
|
|
|
// Root route owns the full HTML document for SSR (<html lang dir> so the tree
|
|
// mirrors for RTL locales) and wraps the app in the Mantine + react-query +
|
|
// Pikku providers. Mantine v8 SSR needs ColorSchemeScript in <head> and
|
|
// mantineHtmlProps on <html>; @mantine/core/styles.css ships the static CSS.
|
|
export const Route = createRootRoute({
|
|
head: () => ({
|
|
meta: [
|
|
{ charSet: 'utf-8' },
|
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
{ title: 'Pikku App' },
|
|
],
|
|
}),
|
|
component: RootDocument,
|
|
})
|
|
|
|
function RootDocument() {
|
|
// Seed from the build-time active theme (active.json) so the applied theme
|
|
// drives the initial + SSR render; useEffect syncs from localStorage after
|
|
// hydration. Seeding 'default' here was the "applied theme never shows" bug.
|
|
const [themeId, setThemeIdRaw] = useState(activeId)
|
|
const [locale, setLocaleRaw] = useState(defaultLocale)
|
|
// Transient override from the fabric console live-preview (not persisted) —
|
|
// the console injects a full theme spec into the iframe so the builder sees
|
|
// the look instantly. Takes precedence over the persisted theme.
|
|
const [previewTheme, setPreviewTheme] = useState<MantineThemeOverride | null>(null)
|
|
// The scheme that goes with the live-preview spec (a light style must render
|
|
// light), tracked alongside previewTheme so a preview flips the scheme too.
|
|
const [previewScheme, setPreviewScheme] = useState<ColorScheme | null>(null)
|
|
|
|
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
|
|
// Force the active/previewed theme's natural scheme — a light-first style boots
|
|
// light, a dark-first style dark — rather than the old hardcoded dark.
|
|
const colorScheme: ColorScheme = previewScheme ?? themeColorSchemes[themeId] ?? activeColorScheme
|
|
|
|
// Sync preferences from localStorage after hydration.
|
|
useEffect(() => {
|
|
const savedTheme = localStorage.getItem(THEME_KEY)
|
|
const savedAgainst = localStorage.getItem(THEME_SAVED_ACTIVE_KEY)
|
|
if (savedTheme && themes[savedTheme] && savedAgainst === activeId) {
|
|
setThemeIdRaw(savedTheme)
|
|
} else if (savedTheme) {
|
|
localStorage.removeItem(THEME_KEY)
|
|
localStorage.removeItem(THEME_SAVED_ACTIVE_KEY)
|
|
}
|
|
|
|
const savedLocale = localStorage.getItem(LOCALE_KEY)
|
|
if (
|
|
savedLocale &&
|
|
supportedLocales.includes(savedLocale as (typeof supportedLocales)[number])
|
|
) {
|
|
setLocaleRaw(savedLocale)
|
|
setActiveLocale(savedLocale as (typeof supportedLocales)[number])
|
|
}
|
|
}, [])
|
|
|
|
// Keep <html lang dir> in sync with locale changes after hydration.
|
|
useEffect(() => {
|
|
document.documentElement.lang = locale
|
|
document.documentElement.dir = localeDir(locale)
|
|
}, [locale])
|
|
|
|
// Fabric console live-preview: postMessage injects a theme spec to override
|
|
// the active theme without persisting. Accepts `theme` (a full spec) or the
|
|
// legacy `palette`; null resets to the persisted theme.
|
|
useEffect(() => {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const data = event.data
|
|
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
|
|
try {
|
|
const spec = (data.theme ?? data.palette) as Theme | null
|
|
setPreviewTheme(spec ? buildMantineTheme(spec) : null)
|
|
setPreviewScheme(spec?.structure?.defaultColorScheme ?? null)
|
|
} catch (err) {
|
|
console.warn('[mantine-themes] ignoring bad theme payload', err)
|
|
}
|
|
}
|
|
window.addEventListener('message', onMessage)
|
|
return () => window.removeEventListener('message', onMessage)
|
|
}, [])
|
|
|
|
const setLocale = (next: string) => {
|
|
localStorage.setItem(LOCALE_KEY, next)
|
|
setLocaleRaw(next)
|
|
setActiveLocale(next as (typeof supportedLocales)[number])
|
|
}
|
|
|
|
const setThemeId = (next: string) => {
|
|
localStorage.setItem(THEME_KEY, next)
|
|
localStorage.setItem(THEME_SAVED_ACTIVE_KEY, activeId)
|
|
setThemeIdRaw(next)
|
|
setPreviewTheme(null)
|
|
setPreviewScheme(null)
|
|
}
|
|
|
|
// Per-render instances keep server requests from sharing client/cache state.
|
|
const [queryClient] = useState(() => new QueryClient())
|
|
const [pikku] = useState(() =>
|
|
createPikku(PikkuFetch, PikkuRPC, {
|
|
serverUrl: apiUrl(),
|
|
credentials: 'include',
|
|
}),
|
|
)
|
|
|
|
return (
|
|
<html lang={locale} dir={localeDir(locale)} {...mantineHtmlProps}>
|
|
<head>
|
|
<HeadContent />
|
|
<ColorSchemeScript defaultColorScheme={activeColorScheme} />
|
|
{/* Inline data-URI icon so the browser's automatic /favicon.ico request
|
|
never 404s (no asset to ship, no network round-trip). Gradient spark
|
|
matches the Wordmark; the build agent rebrands this per project. */}
|
|
<link
|
|
rel="icon"
|
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234f46e5'/%3E%3Cstop offset='1' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='7' fill='url(%23g)'/%3E%3Cpath d='M16 4c1.1 6.4 5.6 10.9 12 12-6.4 1.1-10.9 5.6-12 12-1.1-6.4-5.6-10.9-12-12C10.4 14.9 14.9 10.4 16 4Z' fill='white'/%3E%3C/svg%3E"
|
|
/>
|
|
{fontsHref && (
|
|
<>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
|
<link rel="stylesheet" href={fontsHref} />
|
|
</>
|
|
)}
|
|
</head>
|
|
<body>
|
|
<MantineProvider
|
|
theme={effectiveTheme}
|
|
forceColorScheme={colorScheme === 'auto' ? undefined : colorScheme}
|
|
>
|
|
<PreferencesContext.Provider value={{ locale, themeId, setLocale, setThemeId }}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<PikkuProvider pikku={pikku}>
|
|
<Outlet />
|
|
</PikkuProvider>
|
|
</QueryClientProvider>
|
|
</PreferencesContext.Provider>
|
|
</MantineProvider>
|
|
<Scripts />
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|