chore: starter template

This commit is contained in:
e2e
2026-07-10 20:17:46 +02:00
commit 5c1e65dc5f
157 changed files with 6462 additions and 0 deletions

42
apps/app/messages/en.json Normal file
View File

@@ -0,0 +1,42 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error__title": "This page isn't running properly",
"error__hint": "If an agent is building this app, just wait — it's likely mid-change and will fix this shortly, and the page will refresh on its own.",
"error__retry": "Try again",
"error__details": "Error details",
"common__loading": "Loading…",
"common__email": "Email",
"common__email_placeholder": "you@example.com",
"common__password": "Password",
"common__password_placeholder": "••••••••",
"common__name": "Name",
"common__name_placeholder": "Ada Lovelace",
"validation__required": "Required.",
"validation__email_invalid": "Enter a valid email address.",
"app__name": "Fabric Starter",
"auth__or": "or",
"auth__continue_with_google": "Continue with Google",
"auth__google_error": "Unable to continue with Google right now.",
"auth__login__title": "Sign in",
"auth__login__description": "Welcome back to {name}.",
"auth__login__cta": "Sign in",
"auth__login__invalid_credentials": "That email and password don't match.",
"auth__login__error": "Unable to sign in right now.",
"auth__login__footer_prompt": "Don't have an account?",
"auth__login__footer_action": "Sign up",
"auth__dev_quick_login": "Quick login (dev)",
"auth__dev_quick_login_hint": "Development only — signs in as test@example.com",
"auth__signup__title": "Create your account",
"auth__signup__description": "Get started with {name}.",
"auth__signup__cta": "Create account",
"auth__signup__email_in_use": "An account with this email already exists.",
"auth__signup__error": "Unable to create your account right now.",
"auth__signup__footer_prompt": "Already have an account?",
"auth__signup__footer_action": "Sign in",
"nav__home": "Home",
"app_shell__sign_out": "Sign out",
"home__title": "Hello, world.",
"home__body": "You're signed in to {name}. This is the bare-bones starter every project begins from — describe what you want to build and it gets reshaped from here.",
"preferences__language": "Language",
"preferences__theme": "Theme"
}

36
apps/app/messages/fr.json Normal file
View File

@@ -0,0 +1,36 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common__loading": "Chargement…",
"common__email": "Email",
"common__email_placeholder": "vous@exemple.com",
"common__password": "Mot de passe",
"common__password_placeholder": "••••••••",
"common__name": "Nom",
"common__name_placeholder": "Ada Lovelace",
"validation__required": "Requis.",
"validation__email_invalid": "Saisissez une adresse email valide.",
"app__name": "Fabric Starter",
"auth__or": "ou",
"auth__continue_with_google": "Continuer avec Google",
"auth__google_error": "Impossible de continuer avec Google pour l'instant.",
"auth__login__title": "Se connecter",
"auth__login__description": "Bon retour sur {name}.",
"auth__login__cta": "Se connecter",
"auth__login__invalid_credentials": "Cet email et ce mot de passe ne correspondent pas.",
"auth__login__error": "Impossible de se connecter pour l'instant.",
"auth__login__footer_prompt": "Pas encore de compte ?",
"auth__login__footer_action": "S'inscrire",
"auth__signup__title": "Créez votre compte",
"auth__signup__description": "Commencez avec {name}.",
"auth__signup__cta": "Créer un compte",
"auth__signup__email_in_use": "Un compte avec cet email existe déjà.",
"auth__signup__error": "Impossible de créer votre compte pour l'instant.",
"auth__signup__footer_prompt": "Vous avez déjà un compte ?",
"auth__signup__footer_action": "Se connecter",
"nav__home": "Accueil",
"app_shell__sign_out": "Se déconnecter",
"home__title": "Bonjour, monde.",
"home__body": "Vous êtes connecté à {name}. C'est le point de départ minimal de chaque projet — décrivez ce que vous voulez construire et tout se reconstruit à partir d'ici.",
"preferences__language": "Langue",
"preferences__theme": "Thème"
}

42
apps/app/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@project/app",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 7104",
"build": "vite build",
"preview": "vite preview --port 7104",
"tsc": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/app-tsc.tsbuildinfo"
},
"dependencies": {
"@assistant-ui/react": "^0.14.24",
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@pikku/assistant-ui": "^0.12.7",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5",
"@project/components": "workspace:*",
"@project/functions-sdk": "workspace:*",
"@project/mantine-themes": "workspace:*",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"better-auth": "^1.6.18",
"lucide-react": "^0.456.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@inlang/paraglide-js": "^2.20.0",
"@tanstack/router-generator": "^1.132.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "^5.9",
"vite": "^7.0.0"
}
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "fr"],
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -0,0 +1,90 @@
import { AppShell as MantineAppShell, Box, Button, NavLink, Stack } from '@pikku/mantine/core'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useState, type FC } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { signOut } from '@/lib/auth'
import { Wordmark } from './Wordmark'
import { LanguageSelector } from './LanguageSelector'
import { ThemeSelector } from './ThemeSelector'
const HomeGlyph = () => <NavGlyph d="M3 12 12 4l9 8M5 10v9h5v-6h4v6h5v-9" />
const SignOutGlyph = () => (
<NavGlyph d="M16 17l5-5-5-5M21 12H9M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
)
function NavGlyph({ d }: { d: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
)
}
export const AppShell: FC = () => {
useLocale()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const [isSigningOut, setIsSigningOut] = useState(false)
return (
<MantineAppShell navbar={{ width: 236, breakpoint: 'sm' }} padding="xl">
<MantineAppShell.Navbar p="md">
<Stack h="100%" gap={4}>
<Box px="xs" py="sm">
<Wordmark name={m.app__name()} size={26} />
</Box>
<Stack gap={2} mt="xs">
<NavLink
component={Link}
to="/app"
label={m.nav__home()}
leftSection={<HomeGlyph />}
active={pathname === '/app'}
/>
</Stack>
<Stack gap="xs" mt="auto">
<ThemeSelector />
<LanguageSelector />
<Button
variant="subtle"
color="gray"
fullWidth
justify="flex-start"
leftSection={<SignOutGlyph />}
loading={isSigningOut}
onClick={async () => {
setIsSigningOut(true)
try {
await signOut()
window.location.href = '/login'
} finally {
setIsSigningOut(false)
}
}}
>
{m.app_shell__sign_out()}
</Button>
</Stack>
</Stack>
</MantineAppShell.Navbar>
{/* Flex column so a full-height page (e.g. a chat) can fill the remaining
viewport with just `flex: 1, minHeight: 0` — no viewport math needed.
Content-sized pages are unaffected. */}
<MantineAppShell.Main style={{ display: 'flex', flexDirection: 'column' }}>
<Outlet />
</MantineAppShell.Main>
</MantineAppShell>
)
}

View File

@@ -0,0 +1,227 @@
import type { FC } from 'react'
import type { I18nNode, I18nString } from '@pikku/react'
import { useForm } from '@tanstack/react-form'
import {
Box,
Button,
Card,
Divider,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { Wordmark } from './Wordmark'
export type AuthFormValues = { name: string; email: string; password: string }
type AuthCardProps = {
appName: I18nString
title: I18nString
description: I18nString
cta: I18nString
// Show the Name field (signup) above email.
includeName?: boolean
passwordAutoComplete: 'current-password' | 'new-password'
// Server-side error from the page's mutation (bad credentials, email in use…).
error: I18nString | null
googleBusy: boolean
// Submitting state of the page's mutation; drives the primary button spinner.
busy: boolean
footer: I18nNode
// Called with validated values once the TanStack form passes validation.
onAuthSubmit: (values: AuthFormValues) => void
onGoogle: () => void
// Dev-only: prefill the form (e.g. the test account) when provided.
initialValues?: Partial<AuthFormValues>
// Dev-only: a one-click quick-login action rendered below the form.
quickLogin?: { label: I18nString; hint: I18nString; onClick: () => void; busy: boolean } | null
}
const ArrowGlyph = () => (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
)
const GoogleGlyph = () => (
<svg width="16" height="16" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.57c2.08-1.92 3.27-4.74 3.27-8.09Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.76c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.22V7.04H2.18a11 11 0 0 0 0 9.92l3.66-2.86Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04L5.84 9.9C6.71 7.3 9.14 5.38 12 5.38Z"
/>
</svg>
)
const required = (value: string): I18nString | undefined =>
value.trim() ? undefined : m.validation__required()
const validEmail = (value: string): I18nString | undefined => {
if (!value.trim()) return m.validation__required()
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) ? undefined : m.validation__email_invalid()
}
export const AuthCard: FC<AuthCardProps> = (props) => {
useLocale()
const form = useForm({
defaultValues: { name: '', email: '', password: '', ...props.initialValues } as AuthFormValues,
onSubmit: ({ value }) => props.onAuthSubmit(value),
})
return (
<Box
mih="100vh"
style={{ display: 'grid', placeItems: 'center', background: 'var(--mantine-color-body)' }}
p="xl"
>
<Stack w="100%" maw={380} gap="lg">
<Group justify="center">
<Wordmark name={props.appName} />
</Group>
<Card withBorder radius="lg" shadow="sm" padding="xl">
<Stack gap="lg">
<div>
<Title order={2} fz={21} fw={650} style={{ letterSpacing: '-0.025em' }}>
{props.title}
</Title>
<Text c="dimmed" size="sm" mt={4}>
{props.description}
</Text>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void form.handleSubmit()
}}
>
<Stack gap="sm">
{props.includeName ? (
<form.Field name="name" validators={{ onChange: ({ value }) => required(value) }}>
{(field) => (
<TextInput
label={m.common__name()}
placeholder={m.common__name_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
autoComplete="name"
/>
)}
</form.Field>
) : null}
<form.Field
name="email"
validators={{ onChange: ({ value }) => validEmail(value) }}
>
{(field) => (
<TextInput
label={m.common__email()}
placeholder={m.common__email_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
type="email"
autoComplete="email"
/>
)}
</form.Field>
<form.Field
name="password"
validators={{ onChange: ({ value }) => required(value) }}
>
{(field) => (
<PasswordInput
label={m.common__password()}
placeholder={m.common__password_placeholder()}
value={field.state.value}
onChange={(event) => field.handleChange(event.currentTarget.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0] as I18nString | undefined}
autoComplete={props.passwordAutoComplete}
/>
)}
</form.Field>
{props.error ? (
<Text c="red" size="sm">
{props.error}
</Text>
) : null}
<Button type="submit" loading={props.busy} fullWidth rightSection={<ArrowGlyph />}>
{props.cta}
</Button>
</Stack>
</form>
{props.quickLogin ? (
<Stack gap={4}>
<Button
variant="light"
fullWidth
loading={props.quickLogin.busy}
onClick={props.quickLogin.onClick}
>
{props.quickLogin.label}
</Button>
<Text ta="center" size="xs" c="dimmed">
{props.quickLogin.hint}
</Text>
</Stack>
) : null}
<Divider label={m.auth__or()} labelPosition="center" />
<Button
variant="default"
fullWidth
loading={props.googleBusy}
leftSection={<GoogleGlyph />}
onClick={props.onGoogle}
>
{m.auth__continue_with_google()}
</Button>
</Stack>
</Card>
<Text ta="center" size="sm" c="dimmed">
{props.footer}
</Text>
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Box, Button, Group, Stack, Text, Title } from '@pikku/mantine/core'
import { AlertTriangle, ChevronRight } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
// Router-level error boundary (wired as the router's defaultErrorComponent). In
// dev it reveals the raw error in an expandable section — agents watch for this
// while iterating — while production stays a calm, generic message.
export function DefaultErrorPage({ error }: { error: unknown }) {
useLocale()
const [open, setOpen] = useState(false)
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
return (
<Box
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<Stack align="center" gap="md" style={{ maxWidth: 540, width: '100%' }}>
<Box
style={{
width: 56,
height: 56,
borderRadius: 999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'light-dark(rgba(249, 115, 22, 0.1), rgba(249, 115, 22, 0.15))',
color: '#f97316',
}}
>
<AlertTriangle size={28} />
</Box>
<Title order={2} ta="center">
{m.error__title()}
</Title>
<Text c="dimmed" ta="center" size="sm">
{m.error__hint()}
</Text>
<Group>
<Button variant="light" onClick={() => window.location.reload()}>
{m.error__retry()}
</Button>
</Group>
{import.meta.env.DEV && detail ? (
<Stack gap={6} style={{ width: '100%' }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={
<ChevronRight
size={14}
style={{
transform: open ? 'rotate(90deg)' : undefined,
transition: 'transform 120ms ease',
}}
/>
}
onClick={() => setOpen((v) => !v)}
style={{ alignSelf: 'flex-start' }}
>
{m.error__details()}
</Button>
{open ? (
<Box
component="pre"
style={{
margin: 0,
padding: 12,
borderRadius: 8,
background: 'light-dark(#f8f9fa, #1a1b1e)',
border: '1px solid light-dark(#e9ecef, #2c2e33)',
fontSize: 12,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 320,
overflow: 'auto',
}}
>
{asI18n(detail)}
</Box>
) : null}
</Stack>
) : null}
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,32 @@
import { Select } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { supportedLocales } from '@/i18n/config'
import { usePreferences } from '@/contexts/preferences'
const LOCALE_LABELS: Record<string, string> = {
en: 'English',
fr: 'Français',
}
export function LanguageSelector() {
useLocale()
const { locale, setLocale } = usePreferences()
const data = supportedLocales.map((code) => ({
value: code,
label: LOCALE_LABELS[code] ?? code.toUpperCase(),
}))
return (
<Select
aria-label={m.preferences__language()}
data={data}
value={locale}
onChange={(v) => v && setLocale(v)}
size="xs"
w={110}
allowDeselect={false}
/>
)
}

View File

@@ -0,0 +1,27 @@
import { Select } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { themeList } from '@project/mantine-themes'
import { usePreferences } from '@/contexts/preferences'
// A single theme per app. Themes are normally chosen from the Fabric console
// (which live-injects into the iframe); this picker only appears when more than
// one theme ships, and is hidden otherwise.
export function ThemeSelector() {
useLocale()
const { themeId, setThemeId } = usePreferences()
if (themeList.length <= 1) return null
return (
<Select
aria-label={m.preferences__theme()}
data={themeList.map((t) => ({ value: t.id, label: t.name }))}
value={themeId}
onChange={(v) => v && setThemeId(v)}
size="xs"
w={110}
allowDeselect={false}
/>
)
}

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react'
import { Box, Group, Text } from '@pikku/mantine/core'
import type { I18nString } from '@pikku/react'
// Placeholder brand mark + app name. A gradient tile (brand primary → secondary,
// both theme-driven so it recolours with the active theme) holding a bold spark
// glyph — a confident, generic starting point, NOT a flat monochrome square.
// Reshape the glyph into a real brand mark per project.
export const Wordmark: FC<{ name: I18nString; size?: number }> = ({ name, size = 30 }) => {
return (
<Group gap={10} align="center" wrap="nowrap">
<Box
w={size}
h={size}
style={{
flexShrink: 0,
borderRadius: 'var(--mantine-radius-md)',
// Gradient falls back to the solid primary fill if the theme defines no
// secondary, so it always renders on-brand.
backgroundImage:
'linear-gradient(135deg, var(--mantine-primary-color-filled), var(--mantine-color-secondary-5, var(--mantine-primary-color-filled)))',
color: 'var(--mantine-primary-color-contrast)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<svg width={size * 0.56} height={size * 0.56} viewBox="0 0 24 24" fill="currentColor">
{/* Four-point spark — energetic, distinctive, reads at any size. */}
<path d="M12 1.5c.9 5.1 4.5 8.7 9.6 9.6v1.8c-5.1.9-8.7 4.5-9.6 9.6h-1.8c-.9-5.1-4.5-8.7-9.6-9.6v-1.8c5.1-.9 8.7-4.5 9.6-9.6Z" />
</svg>
</Box>
<Text fw={700} style={{ fontSize: size * 0.55, letterSpacing: '-0.02em' }}>
{name}
</Text>
</Group>
)
}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
export interface PreferencesContextValue {
locale: string
themeId: string
setLocale: (locale: string) => void
setThemeId: (themeId: string) => void
}
export const PreferencesContext = createContext<PreferencesContextValue>({
locale: 'en',
themeId: 'default',
setLocale: () => {},
setThemeId: () => {},
})
export function usePreferences(): PreferencesContextValue {
return useContext(PreferencesContext)
}

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { getAuthSession } from '@/lib/auth'
// Client-side auth gates. They run only after hydration (useEffect never fires
// during SSR), so the redirect decision never depends on the SSR worker seeing
// the session cookie — which it can't on the multi-tenant pikkufabric.dev
// domain, where the Auth.js cookie is host-only on the API origin and so is not
// sent to the app host on a server-rendered request. The client fetch to the
// API host does carry that cookie, so the check is correct on every deploy
// topology (local, platform subdomains, custom domain).
//
// Trade-off: a logged-out visitor to /app sees the shell for a moment before
// being redirected. On a custom domain (single tenant, cookie Domain=.<parent>)
// you could add a createServerFn + server-side gate to remove that flash —
// deferred; not safe to do generically on the shared platform domain.
export function useRedirectIfAuthenticated() {
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
void getAuthSession().then((session) => {
if (!cancelled && session.user?.email) {
void navigate({ to: '/app' })
}
})
return () => {
cancelled = true
}
}, [navigate])
}
export function useRequireAuthentication() {
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
void getAuthSession().then((session) => {
if (!cancelled && !session.user?.email) {
void navigate({ to: '/login' })
}
})
return () => {
cancelled = true
}
}, [navigate])
}

View File

@@ -0,0 +1,87 @@
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
// message functions (`m`).
//
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
import { useSyncExternalStore } from 'react'
import { locales, baseLocale, overwriteGetLocale } from '../paraglide/runtime.js'
export const supportedLocales = locales
export const defaultLocale = baseLocale
export type Locale = (typeof locales)[number]
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
export function detectLocale(pathname: string): Locale {
const segment = pathname.split('/')[1]
if (supportedLocales.includes(segment as Locale)) return segment as Locale
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language?.split('-')[0]
if (supportedLocales.includes(browserLang as Locale)) return browserLang as Locale
}
return defaultLocale
}
// ── reactive locale store ────────────────────────────────────────────────────
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
// it to a tiny external store: `getLocale` reads `activeLocale`; components
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
let activeLocale: Locale = defaultLocale
const listeners = new Set<() => void>()
overwriteGetLocale(() => activeLocale)
export function setActiveLocale(next: Locale): void {
if (next === activeLocale) return
activeLocale = next
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
// used to live; it also returns the active locale + direction for components
// that need them (e.g. the language switcher).
export function useLocale(): {
locale: Locale
dir: 'ltr' | 'rtl'
setLocale: (l: Locale) => void
} {
const locale = useSyncExternalStore<Locale>(
subscribe,
() => activeLocale,
() => activeLocale,
)
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
}
// ── i18n-debug masking ───────────────────────────────────────────────────────
// When enabled, every *translated* string is masked to block glyphs (█) so any
// readable text left on screen is text that never went through a message (a
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
export function maskI18n(s: string): string {
return isI18nDebug() ? s.replace(/\S/g, '█') : s
}

View File

@@ -0,0 +1,33 @@
// Typed message functions for the app. `m.landing_title()` returns a branded
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
// `t('landing.title')` used to — no call-site boilerplate.
//
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
// per-message tree-shaking — fine here: locale files are KB and the app bundled
// all of en.json under i18next anyway.
import { m as _m } from '../paraglide/messages.js'
import type { I18nString } from '@pikku/react'
import { maskI18n } from './config.js'
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
const _wrapped: Record<string, unknown> = {}
for (const key of Object.keys(_raw)) {
const fn = _raw[key]
_wrapped[key] =
typeof fn === 'function'
? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args))
: fn
}
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
export const m = _wrapped as unknown as Branded<typeof _m>
// Re-export asI18n so every call site imports BOTH i18n helpers from this one
// module — `import { asI18n, m } from '@/i18n/messages'`. asI18n brands an opaque
// runtime string (a server name/slug/id or a formatted date/number) so it passes
// the @pikku/mantine i18n gate; m.key() is for static copy. Keep them co-located.
export { asI18n } from '@pikku/react'

133
apps/app/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,133 @@
import { createAuthClient } from 'better-auth/client'
import { apiUrl } from './env'
// Better Auth browser client. It targets the worker's catch-all `/api/auth/**`
// routes (generated from src/auth.ts in the functions package). Pass the FULL
// auth base (`<apiUrl>/auth`): better-auth's withPath only appends its default
// `/api/auth` when baseURL has no path, and apiUrl() already carries `/api`, so
// a bare apiUrl() would leave the client calling `/api/get-session` (404) and
// the app would loop back to /login. Cookies ride every request so the session
// round-trips across origins.
//
// Lazily constructed: createAuthClient validates baseURL with `new URL()` at
// construction, so building it at module scope crashes SSR (apiUrl() returns
// the relative `/__api` placeholder there). Every caller runs in the browser.
let _authClient: ReturnType<typeof createAuthClient> | null = null
function authClient() {
_authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` })
return _authClient
}
export type AuthSession = {
user?: {
email?: string | null
name?: string | null
image?: string | null
} | null
expires?: string
}
// Thrown by signInWithPassword when the email/password pair is wrong.
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
// Thrown by registerWithPassword when the email is already taken.
export const EMAIL_IN_USE = 'EMAIL_IN_USE'
export async function getAuthSession(): Promise<AuthSession> {
const { data } = await authClient().getSession()
if (!data?.user) {
return {}
}
return {
user: {
email: data.user.email,
name: data.user.name ?? null,
image: data.user.image ?? null,
},
expires: data.session?.expiresAt ? new Date(data.session.expiresAt).toISOString() : undefined,
}
}
// Sign in with an email and password. On success Better Auth sets the session
// cookie; on bad credentials this throws INVALID_CREDENTIALS.
export async function signInWithPassword(email: string, password: string, _redirectPath = '/app') {
const { error } = await authClient().signIn.email({ email, password })
if (error) {
throw new Error(INVALID_CREDENTIALS)
}
}
// Create a new account. Better Auth signs the user in on success (sets the
// session cookie), so they land logged in.
export async function registerWithPassword(
email: string,
password: string,
options: { name?: string; redirectPath?: string } = {},
) {
const { error } = await authClient().signUp.email({
email,
password,
// Better Auth requires a name; fall back to the local-part of the email.
name: options.name?.trim() || email.split('@')[0],
})
if (error) {
// Better Auth returns 422 (UNPROCESSABLE_ENTITY) when the email is taken.
if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) {
throw new Error(EMAIL_IN_USE)
}
throw new Error('Unable to create account')
}
}
// Dev-only convenience account. The Quick-login button (rendered only in non-prod
// builds — sandboxes run `vite dev`, deployments are production builds where
// import.meta.env.DEV is false) signs in with these so you never sign up by hand.
export const DEV_LOGIN = { email: 'test@example.com', password: 'password' } as const
// Sign in with the dev account, provisioning it on first use. Idempotent: if the
// account already exists we just sign in; a concurrent create (EMAIL_IN_USE) is
// fine. Only ever called from the dev-gated Quick-login button.
export async function devQuickLogin(): Promise<void> {
// The account usually already exists — try signing in first. A failure here is
// expected on the very first run (not yet provisioned), so fall through.
const signedIn = await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
.then(() => true)
.catch(() => false)
if (signedIn) return
await registerWithPassword(DEV_LOGIN.email, DEV_LOGIN.password, { name: 'Test User' }).catch(
(err) => {
// Another tab/request may have created it between our sign-in and sign-up.
if (!(err instanceof Error && err.message === EMAIL_IN_USE)) throw err
},
)
await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
}
// Continue with Google. Better Auth redirects the browser to the provider and
// back to `callbackURL` on success, so this never resolves on the happy path —
// it throws only when the provider isn't configured / the request is rejected.
// Configure the Google provider in the functions package's auth config to enable
// it; until then the button surfaces the error via useMutation.
export async function signInWithGoogle(callbackURL = '/app') {
const { error } = await authClient().signIn.social({ provider: 'google', callbackURL })
if (error) {
throw new Error('Unable to continue with Google')
}
}
// Change the signed-in user's password. Better Auth requires the current
// password to authorize the change.
export async function changePassword(currentPassword: string, newPassword: string) {
const { error } = await authClient().changePassword({ currentPassword, newPassword })
if (error) {
throw new Error('Unable to update password')
}
}
export async function signOut() {
const { error } = await authClient().signOut()
if (error) {
throw new Error('Unable to sign out')
}
}

18
apps/app/src/lib/env.ts Normal file
View File

@@ -0,0 +1,18 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time via .dev.vars and Vite
// replaces the import.meta.env reference inline. In CF Workers deployments
// VITE_API_URL is a runtime text binding — invisible to Vite at build time —
// so import.meta.env.VITE_API_URL is undefined in the bundle. We fall back to
// the current origin + /api, which is correct because the dispatcher worker
// routes every /api/* path to the API units on the same hostname.
export function apiUrl(): string {
if (import.meta.env.SSR) {
// SSR context: PikkuProvider is a client-side provider and its serverUrl
// is only consumed by hooks that run in the browser. Return the build-time
// var when available (local dev) or a placeholder so SSR never throws.
return import.meta.env.VITE_API_URL ?? '/__api'
}
// Client: use the build-time var (local dev) or derive from current origin.
return import.meta.env.VITE_API_URL ?? window.location.origin + '/api'
}

View File

@@ -0,0 +1,22 @@
import type { FC } from 'react'
import { Box, Stack, Text, Title } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const HomePage: FC = () => {
useLocale()
const appName = m.app__name()
return (
<Box mih="70vh" style={{ display: 'grid', placeItems: 'center' }}>
<Stack maw={520} ta="center" gap="md">
<Title order={1} fz={34} fw={650} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
{m.home__title()}
</Title>
<Text c="dimmed" size="md" style={{ lineHeight: 1.6 }}>
{m.home__body({ name: appName })}
</Text>
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,78 @@
import type { FC } from 'react'
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation } from '@tanstack/react-query'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
import {
DEV_LOGIN,
INVALID_CREDENTIALS,
devQuickLogin,
signInWithGoogle,
signInWithPassword,
} from '@/lib/auth'
export const LoginPage: FC = () => {
useLocale()
const navigate = useNavigate()
const appName = m.app__name()
// Dev builds (sandboxes run `vite dev`) prefill the test account and expose a
// one-click quick-login; production builds strip both (import.meta.env.DEV).
const isDev = import.meta.env.DEV
const signIn = useMutation({
mutationFn: (values: AuthFormValues) =>
signInWithPassword(values.email, values.password, '/app'),
onSuccess: () => navigate({ to: '/app' }),
})
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
const quick = useMutation({
mutationFn: () => devQuickLogin(),
onSuccess: () => navigate({ to: '/app' }),
})
const error = signIn.isError
? signIn.error instanceof Error && signIn.error.message === INVALID_CREDENTIALS
? m.auth__login__invalid_credentials()
: m.auth__login__error()
: google.isError
? m.auth__google_error()
: quick.isError
? m.auth__login__error()
: null
return (
<AuthCard
appName={appName}
title={m.auth__login__title()}
description={m.auth__login__description({ name: appName })}
cta={m.auth__login__cta()}
passwordAutoComplete="current-password"
busy={signIn.isPending}
googleBusy={google.isPending}
error={error}
initialValues={isDev ? { email: DEV_LOGIN.email, password: DEV_LOGIN.password } : undefined}
quickLogin={
isDev
? {
label: m.auth__dev_quick_login(),
hint: m.auth__dev_quick_login_hint(),
onClick: () => quick.mutate(),
busy: quick.isPending,
}
: null
}
onAuthSubmit={(values) => signIn.mutate(values)}
onGoogle={() => google.mutate()}
footer={
<>
{m.auth__login__footer_prompt()}{' '}
<Anchor component={Link} to="/signup">
{m.auth__login__footer_action()}
</Anchor>
</>
}
/>
)
}

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react'
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useMutation } from '@tanstack/react-query'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
import { EMAIL_IN_USE, registerWithPassword, signInWithGoogle } from '@/lib/auth'
export const SignupPage: FC = () => {
useLocale()
const navigate = useNavigate()
const appName = m.app__name()
const signUp = useMutation({
mutationFn: (values: AuthFormValues) =>
registerWithPassword(values.email, values.password, {
name: values.name,
redirectPath: '/app',
}),
onSuccess: () => navigate({ to: '/app' }),
})
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
const error = signUp.isError
? signUp.error instanceof Error && signUp.error.message === EMAIL_IN_USE
? m.auth__signup__email_in_use()
: m.auth__signup__error()
: google.isError
? m.auth__google_error()
: null
return (
<AuthCard
appName={appName}
title={m.auth__signup__title()}
description={m.auth__signup__description({ name: appName })}
cta={m.auth__signup__cta()}
includeName
passwordAutoComplete="new-password"
busy={signUp.isPending}
googleBusy={google.isPending}
error={error}
onAuthSubmit={(values) => signUp.mutate(values)}
onGoogle={() => google.mutate()}
footer={
<>
{m.auth__signup__footer_prompt()}{' '}
<Anchor component={Link} to="/login">
{m.auth__signup__footer_action()}
</Anchor>
</>
}
/>
)
}

22
apps/app/src/router.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { DefaultErrorPage } from '@/components/DefaultErrorPage'
// TanStack Start discovers this `getRouter` factory to build a router per
// request (server) and once on the client (hydration). routeTree.gen.ts is
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
// hand-edit it.
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultErrorComponent: DefaultErrorPage,
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { HomePage } from '@/pages/HomePage'
export const Route = createFileRoute('/app/')({
component: HomePage,
})

View 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 />
}

View 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' })
},
})

View 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 />
}

View 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 />
}

23
apps/app/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowJs": true,
"checkJs": false,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts", "wrangler.jsonc"]
}

59
apps/app/vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import { fileURLToPath, URL } from 'node:url'
// Plain TanStack Start config (Node target) — used for local sandbox dev. This
// template is deploy-provider agnostic: it ships NO @cloudflare/vite-plugin. At
// deploy, fabric CI injects the CF adapter (vite.config.cf.ts merges cloudflare()
// on top of this config) to emit the CF Workers SSR bundle. Do not add cloudflare
// here — that would double-apply it and couple the template to a provider.
export default defineConfig({
plugins: [
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR
// on message edits. Must run first.
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
tanstackStart(),
react(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '0.0.0.0',
allowedHosts: true,
// Local dev only — mirrors the sandbox edge (Caddy): /api/auth/* keeps its
// prefix (Better Auth mounts there), every other /api/* reaches the pikku
// dev server unprefixed (/rpc/...). In the sandbox Caddy handles /api
// before Vite, so this proxy never fires there.
// changeOrigin stays false so the backend sees Host = the app origin —
// Better Auth trusts the request origin only when it matches Host.
proxy: {
'/api/auth': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
'/api': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
// File content: the pikku dev server serves uploads (PUT) and assets (GET)
// AT these prefixes (pikku.config.json content.uploadUrlPrefix/assetUrlPrefix),
// so proxy them through WITHOUT a rewrite. `/content` (not `/assets`) avoids
// colliding with Vite/TanStack's own built asset paths. In the sandbox Caddy
// handles these before Vite, so this proxy only fires in local dev.
'/upload': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
'/content': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: false,
},
},
},
})

9
apps/app/wrangler.jsonc Normal file
View File

@@ -0,0 +1,9 @@
{
// Consumed by @cloudflare/vite-plugin. `main` is TanStack Start's built-in
// server entry (no hand-written worker file); the plugin wires client assets
// and the ssr environment automatically.
"name": "app",
"main": "@tanstack/react-start/server-entry",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
}