chore: starter template
This commit is contained in:
178
apps/app/src/routes/__root.tsx
Normal file
178
apps/app/src/routes/__root.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
6
apps/app/src/routes/app.index.tsx
Normal file
6
apps/app/src/routes/app.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { HomePage } from '@/pages/HomePage'
|
||||
|
||||
export const Route = createFileRoute('/app/')({
|
||||
component: HomePage,
|
||||
})
|
||||
12
apps/app/src/routes/app.tsx
Normal file
12
apps/app/src/routes/app.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { useRequireAuthentication } from '@/hooks/useAuthGate'
|
||||
|
||||
export const Route = createFileRoute('/app')({
|
||||
component: AppLayout,
|
||||
})
|
||||
|
||||
function AppLayout() {
|
||||
useRequireAuthentication()
|
||||
return <AppShell />
|
||||
}
|
||||
11
apps/app/src/routes/index.tsx
Normal file
11
apps/app/src/routes/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
// The starter has no marketing landing — entry is the sign-in screen. Redirect
|
||||
// at the router level so it runs during SSR (and every navigation), instead of a
|
||||
// client-only effect that leaves `/` blank until hydration. Signed-in visitors
|
||||
// are then bounced to /app by /login's own auth gate.
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/login' })
|
||||
},
|
||||
})
|
||||
12
apps/app/src/routes/login.tsx
Normal file
12
apps/app/src/routes/login.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginRoute,
|
||||
})
|
||||
|
||||
function LoginRoute() {
|
||||
useRedirectIfAuthenticated()
|
||||
return <LoginPage />
|
||||
}
|
||||
12
apps/app/src/routes/signup.tsx
Normal file
12
apps/app/src/routes/signup.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SignupPage } from '@/pages/SignupPage'
|
||||
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
|
||||
|
||||
export const Route = createFileRoute('/signup')({
|
||||
component: SignupRoute,
|
||||
})
|
||||
|
||||
function SignupRoute() {
|
||||
useRedirectIfAuthenticated()
|
||||
return <SignupPage />
|
||||
}
|
||||
Reference in New Issue
Block a user