chore: server-and-serverless template

This commit is contained in:
e2e
2026-06-28 12:36:05 +02:00
commit b21fe5a987
202 changed files with 9063 additions and 0 deletions

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

@@ -0,0 +1,61 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common__loading": "Loading…",
"common__email": "Email",
"common__email_placeholder": "you@example.com",
"common__password": "Password",
"common__password_placeholder": "Your password",
"landing__eyebrow": "Starter template",
"landing__title": "Start with the smallest real Pikku app.",
"landing__subtitle": "This starter gives an AI the full path: routed frontend, generated data hooks, auth, session guard, and a tiny backend it can safely expand.",
"landing__create_account": "Create account",
"landing__sign_in": "Sign in",
"landing__cards__frontend__title": "Frontend",
"landing__cards__frontend__description": "Vite, TanStack Router, React Query, and generated Pikku hooks.",
"landing__cards__backend__title": "Backend",
"landing__cards__backend__description": "One protected read function, one update function, and matching HTTP routes.",
"landing__cards__auth__title": "Auth",
"landing__cards__auth__description": "Email and password login, cookie sessions, and a protected app route.",
"auth__eyebrow": "Starter template",
"auth__login__title": "Welcome back",
"auth__login__description": "Sign in with your email and password.",
"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": "Need an account?",
"auth__login__footer_action": "Create one",
"auth__signup__title": "Create your account",
"auth__signup__description": "Pick an email and password to get started.",
"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",
"auth__verify_email__title": "Confirm your email",
"auth__verify_email__verifying": "Confirming your email…",
"auth__verify_email__success": "Your email is confirmed. You're all set.",
"auth__verify_email__error": "This confirmation link is invalid or has expired.",
"auth__verify_email__no_token": "This page needs a confirmation link from your email.",
"auth__verify_email__back_to_app": "Go to your app",
"app_shell__eyebrow": "Pikku + Vite + Auth.js",
"app_shell__title": "Starter",
"app_shell__signed_in": "Signed in",
"app_shell__sign_out": "Sign out",
"app_shell__nav__message": "Message",
"app_shell__nav__message_description": "Starter read + update flow",
"preferences__language": "Language",
"preferences__theme": "Theme",
"message__loading": "Loading your starter app…",
"message__load_error": "The starter app could not load its message.",
"message__read__eyebrow": "Read hook",
"message__read__title": "Current message",
"message__read__description": "This data comes from a generated `usePikkuQuery` hook.",
"message__read__last_updated_by": "Last updated by {name}",
"message__read__nobody": "Nobody yet",
"message__update__eyebrow": "Mutate hook",
"message__update__title": "Update the message",
"message__update__description": "This form calls a generated `usePikkuMutation` hook and refreshes the read state.",
"message__update__field_label": "Message",
"message__update__placeholder": "Write the next starter message",
"message__update__cta": "Update message"
}

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

@@ -0,0 +1,61 @@
{
"$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": "Votre mot de passe",
"landing__eyebrow": "Modèle de démarrage",
"landing__title": "Commencez avec la plus petite vraie application Pikku.",
"landing__subtitle": "Ce modèle donne à une IA le chemin complet : frontend routé, hooks de données générés, auth, garde de session et un petit backend qu'elle peut étendre.",
"landing__create_account": "Créer un compte",
"landing__sign_in": "Se connecter",
"landing__cards__frontend__title": "Frontend",
"landing__cards__frontend__description": "Vite, TanStack Router, React Query et des hooks Pikku générés.",
"landing__cards__backend__title": "Backend",
"landing__cards__backend__description": "Une fonction de lecture protégée, une fonction de mise à jour et les routes HTTP correspondantes.",
"landing__cards__auth__title": "Authentification",
"landing__cards__auth__description": "Connexion par email et mot de passe, sessions en cookies et route d'application protégée.",
"auth__eyebrow": "Modèle de démarrage",
"auth__login__title": "Bon retour",
"auth__login__description": "Connectez-vous avec votre email et votre mot de passe.",
"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": "En créer un",
"auth__signup__title": "Créez votre compte",
"auth__signup__description": "Choisissez un email et un mot de passe pour commencer.",
"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",
"auth__verify_email__title": "Confirmez votre email",
"auth__verify_email__verifying": "Confirmation de votre email…",
"auth__verify_email__success": "Votre email est confirmé. Tout est prêt.",
"auth__verify_email__error": "Ce lien de confirmation est invalide ou a expiré.",
"auth__verify_email__no_token": "Cette page nécessite un lien de confirmation depuis votre email.",
"auth__verify_email__back_to_app": "Aller à votre application",
"app_shell__eyebrow": "Pikku + Vite + Auth.js",
"app_shell__title": "Starter",
"app_shell__signed_in": "Connecté",
"app_shell__sign_out": "Se déconnecter",
"app_shell__nav__message": "Message",
"app_shell__nav__message_description": "Flux lecture + mise à jour de démarrage",
"message__loading": "Chargement de votre application…",
"message__load_error": "L'application n'a pas pu charger son message.",
"message__read__eyebrow": "Hook de lecture",
"message__read__title": "Message actuel",
"message__read__description": "Ces données proviennent d'un hook `usePikkuQuery` généré.",
"message__read__last_updated_by": "Dernière mise à jour par {name}",
"message__read__nobody": "Personne encore",
"message__update__eyebrow": "Hook de mutation",
"message__update__title": "Modifier le message",
"message__update__description": "Ce formulaire appelle un hook `usePikkuMutation` généré et actualise l'état de lecture.",
"message__update__field_label": "Message",
"message__update__placeholder": "Écrivez le prochain message de démarrage",
"message__update__cta": "Mettre à jour le message",
"preferences__language": "Langue",
"preferences__theme": "Thème"
}

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

@@ -0,0 +1,36 @@
{
"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"
},
"dependencies": {
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@pikku/mantine": "^0.12.5",
"@pikku/react": "^0.12.4",
"@project/functions-sdk": "workspace:*",
"@project/mantine-themes": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"better-auth": "^1.6.18",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@inlang/paraglide-js": "^2.20.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,11 @@
{
"$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,103 @@
import {
AppShell as MantineAppShell,
Box,
Button,
Group,
NavLink,
Stack,
Text,
Title,
} from '@pikku/mantine/core'
import { Link, Outlet } from '@tanstack/react-router'
import { asI18n } from '@pikku/react'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { signOut } from '@/lib/auth'
import { LanguageSelector } from './LanguageSelector'
import { ThemeSelector } from './ThemeSelector'
export function AppShell() {
useLocale()
const session = usePikkuQuery('getSession', {})
const [isSigningOut, setIsSigningOut] = useState(false)
return (
<MantineAppShell
header={{ height: 88 }}
navbar={{ width: 250, breakpoint: 'sm' }}
padding="lg"
styles={{
main: {
background:
'radial-gradient(circle at top left, rgba(96, 165, 250, 0.12), transparent 26%), linear-gradient(180deg, #10203a 0%, #07111e 56%, #040913 100%)',
},
}}
>
<MantineAppShell.Header px="lg" py="md">
<Group justify="space-between" align="center" h="100%">
<div>
<Text tt="uppercase" fw={700} fz="xs" c="blue.3" style={{ letterSpacing: '0.18em' }}>
{m.app_shell__eyebrow()}
</Text>
<Title order={2}>{m.app_shell__title()}</Title>
</div>
<Group gap="md">
<LanguageSelector />
<ThemeSelector />
<Box
px="md"
py="xs"
style={{
border: '1px solid var(--mantine-color-dark-4)',
borderRadius: '1rem',
background: 'rgba(255,255,255,0.03)',
}}
>
<Text size="xs" c="dimmed">
{session.data?.name ? asI18n(session.data.name) : m.app_shell__signed_in()}
</Text>
<Text fw={600}>
{session.data?.email ? asI18n(session.data.email) : m.common__loading()}
</Text>
</Box>
<Button
variant="light"
radius="md"
loading={isSigningOut}
onClick={async () => {
setIsSigningOut(true)
try {
await signOut()
window.location.href = '/login'
} finally {
setIsSigningOut(false)
}
}}
>
{m.app_shell__sign_out()}
</Button>
</Group>
</Group>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="md">
<Stack gap="xs">
<NavLink
component={Link}
to="/app"
label={m.app_shell__nav__message()}
description={m.app_shell__nav__message_description()}
variant="filled"
activeOptions={{ exact: true }}
/>
</Stack>
</MantineAppShell.Navbar>
<MantineAppShell.Main>
<Outlet />
</MantineAppShell.Main>
</MantineAppShell>
)
}

View File

@@ -0,0 +1,105 @@
import type { FormEvent } from 'react'
import type { I18nNode, I18nString } from '@pikku/react'
import {
Box,
Button,
Card,
Container,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
type AuthCardProps = {
title: I18nString
description: I18nString
cta: I18nString
email: string
password: string
// 'current-password' on login (let the browser fill a saved password),
// 'new-password' on signup (let it suggest a strong one).
passwordAutoComplete: 'current-password' | 'new-password'
busy: boolean
message: I18nString | null
error: I18nString | null
footer: I18nNode
onEmailChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}
export function AuthCard(props: AuthCardProps) {
useLocale()
return (
<Box
mih="100vh"
style={{
background:
'radial-gradient(circle at top left, rgba(96, 165, 250, 0.16), transparent 30%), linear-gradient(180deg, #10203a 0%, #07111e 56%, #040913 100%)',
}}
py="xl"
>
<Container size={560}>
<Card radius="xl" padding="xl" withBorder shadow="xl" bg="rgba(10, 20, 36, 0.86)">
<Stack gap="lg">
<div>
<Text
tt="uppercase"
fw={700}
fz="xs"
c="blue.3"
mb="sm"
style={{ letterSpacing: '0.18em' }}
>
{m.auth__eyebrow()}
</Text>
<Title order={1}>{props.title}</Title>
<Text c="dimmed" mt="sm" maw={440}>
{props.description}
</Text>
</div>
<form onSubmit={props.onSubmit}>
<Stack gap="md">
<TextInput
label={m.common__email()}
placeholder={m.common__email_placeholder()}
value={props.email}
onChange={(event) => props.onEmailChange(event.currentTarget.value)}
required
radius="md"
type="email"
autoComplete="email"
/>
<PasswordInput
label={m.common__password()}
placeholder={m.common__password_placeholder()}
value={props.password}
onChange={(event) => props.onPasswordChange(event.currentTarget.value)}
required
radius="md"
autoComplete={props.passwordAutoComplete}
/>
<Button type="submit" loading={props.busy} radius="md" size="md">
{props.cta}
</Button>
</Stack>
</form>
{props.message ? <Text c="teal.3">{props.message}</Text> : null}
{props.error ? <Text c="red.3">{props.error}</Text> : null}
<Text c="dimmed" size="sm">
{props.footer}
</Text>
</Stack>
</Card>
</Container>
</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,51 @@
import { Card, Stack, Text, Title } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
type MessageCardProps = {
message: string
updatedAt: string
updatedBy: {
email: string
name: string | null
} | null
}
export function MessageCard(props: MessageCardProps) {
useLocale()
const updatedBy = props.updatedBy?.name || props.updatedBy?.email || m.message__read__nobody()
return (
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
<Stack gap="lg">
<div>
<Text
tt="uppercase"
fw={700}
fz="xs"
c="blue.3"
mb="sm"
style={{ letterSpacing: '0.18em' }}
>
{m.message__read__eyebrow()}
</Text>
<Title order={2}>{m.message__read__title()}</Title>
<Text c="dimmed" mt="sm">
{m.message__read__description()}
</Text>
</div>
<Card radius="lg" padding="xl" bg="rgba(255,255,255,0.03)" withBorder>
<Stack gap="xs">
<Title order={1} fz="2.35rem">
{asI18n(props.message)}
</Title>
<Text c="dimmed">{m.message__read__last_updated_by({ name: updatedBy })}</Text>
<Text c="dimmed">{asI18n(new Date(props.updatedAt).toLocaleString())}</Text>
</Stack>
</Card>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,27 @@
import { Select } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { palettes } from '@project/mantine-themes'
import { usePreferences } from '@/contexts/preferences'
export function ThemeSelector() {
useLocale()
const { themeId, setThemeId } = usePreferences()
const data = Object.entries(palettes as Record<string, { name: string }>).map(([id, p]) => ({
value: id,
label: p.name,
}))
return (
<Select
aria-label={m.preferences__theme()}
data={data}
value={themeId}
onChange={(v) => v && setThemeId(v)}
size="xs"
w={110}
allowDeselect={false}
/>
)
}

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,5 @@
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
export function useMessage() {
return usePikkuQuery('getMessage', {})
}

View File

@@ -0,0 +1,14 @@
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
export function useUpdateMessage() {
const queryClient = useQueryClient()
return usePikkuMutation('updateMessage', {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['getMessage', {}],
})
},
})
}

View File

@@ -0,0 +1,79 @@
// 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,24 @@
// 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>

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

@@ -0,0 +1,85 @@
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.
export const authClient = createAuthClient({ baseURL: `${apiUrl()}/auth` })
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')
}
}
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,76 @@
import { Button, Card, Container, Grid, Group, Stack, Text, Title } from '@pikku/mantine/core'
import { Link } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import type { I18nString } from '@pikku/react'
import { useLocale } from '@/i18n/config'
type CardKey = 'frontend' | 'backend' | 'auth'
const CARD_LABELS: Record<CardKey, { title: () => I18nString; description: () => I18nString }> = {
frontend: { title: m.landing__cards__frontend__title, description: m.landing__cards__frontend__description },
backend: { title: m.landing__cards__backend__title, description: m.landing__cards__backend__description },
auth: { title: m.landing__cards__auth__title, description: m.landing__cards__auth__description },
}
export function LandingPage() {
useLocale()
const cards = ['frontend', 'backend', 'auth'] as const
return (
<Container
size="lg"
py={72}
style={{
minHeight: '100vh',
}}
>
<Grid gap="xl" align="center">
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="xl">
<div>
<Text
tt="uppercase"
fw={700}
fz="xs"
c="blue.3"
mb="sm"
style={{ letterSpacing: '0.18em' }}
>
{m.landing__eyebrow()}
</Text>
<Title order={1} fz={{ base: '2.8rem', md: '4.5rem' }} lh={0.95}>
{m.landing__title()}
</Title>
<Text c="dimmed" mt="lg" maw={620} size="lg">
{m.landing__subtitle()}
</Text>
</div>
<Group>
<Button component={Link} to="/signup" radius="md" size="md">
{m.landing__create_account()}
</Button>
<Button component="a" href="/login" variant="light" radius="md" size="md">
{m.landing__sign_in()}
</Button>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="md">
{cards.map((key) => (
<Card key={key} radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
<Text fw={700}>{CARD_LABELS[key].title()}</Text>
<Text c="dimmed" mt="xs">
{CARD_LABELS[key].description()}
</Text>
</Card>
))}
</Stack>
</Grid.Col>
</Grid>
</Container>
)
}

View File

@@ -0,0 +1,61 @@
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'
import type { I18nString } from '@pikku/react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard } from '@/components/AuthCard'
import { INVALID_CREDENTIALS, signInWithPassword } from '@/lib/auth'
export function LoginPage() {
useLocale()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<I18nString | null>(null)
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await signInWithPassword(email, password, '/app')
await navigate({ to: '/app' })
} catch (err) {
setError(
err instanceof Error && err.message === INVALID_CREDENTIALS
? m.auth__login__invalid_credentials()
: m.auth__login__error(),
)
} finally {
setBusy(false)
}
}
return (
<AuthCard
title={m.auth__login__title()}
description={m.auth__login__description()}
cta={m.auth__login__cta()}
email={email}
password={password}
passwordAutoComplete="current-password"
busy={busy}
message={null}
error={error}
onEmailChange={setEmail}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
footer={
<>
{m.auth__login__footer_prompt()}{' '}
<Anchor component={Link} to="/signup">
{m.auth__login__footer_action()}
</Anchor>
</>
}
/>
)
}

View File

@@ -0,0 +1,88 @@
import { Button, Card, Grid, Stack, Text, Textarea, Title } from '@pikku/mantine/core'
import { useState, type FormEvent } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { MessageCard } from '@/components/MessageCard'
import { useMessage } from '@/hooks/useMessage'
import { useUpdateMessage } from '@/hooks/useUpdateMessage'
export function MessagePage() {
useLocale()
const messageQuery = useMessage()
const updateMessage = useUpdateMessage()
const [message, setMessage] = useState('')
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
await updateMessage.mutateAsync({ message })
setMessage('')
}
if (messageQuery.isLoading) {
return (
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
<Text c="dimmed">{m.message__loading()}</Text>
</Card>
)
}
if (messageQuery.error || !messageQuery.data) {
return (
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
<Text c="red.3">{m.message__load_error()}</Text>
</Card>
)
}
return (
<Grid gap="lg">
<Grid.Col span={{ base: 12, md: 7 }}>
<MessageCard
message={messageQuery.data.message}
updatedAt={messageQuery.data.updatedAt}
updatedBy={messageQuery.data.updatedBy}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 5 }}>
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
<Stack gap="lg">
<div>
<Text
tt="uppercase"
fw={700}
fz="xs"
c="blue.3"
mb="sm"
style={{ letterSpacing: '0.18em' }}
>
{m.message__update__eyebrow()}
</Text>
<Title order={2}>{m.message__update__title()}</Title>
<Text c="dimmed" mt="sm">
{m.message__update__description()}
</Text>
</div>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Textarea
label={m.message__update__field_label()}
rows={4}
placeholder={m.message__update__placeholder()}
value={message}
onChange={(event) => setMessage(event.currentTarget.value)}
required
radius="md"
/>
<Button type="submit" loading={updateMessage.isPending} radius="md">
{m.message__update__cta()}
</Button>
</Stack>
</form>
</Stack>
</Card>
</Grid.Col>
</Grid>
)
}

View File

@@ -0,0 +1,61 @@
import { Anchor } from '@pikku/mantine/core'
import { Link, useNavigate } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'
import type { I18nString } from '@pikku/react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { AuthCard } from '@/components/AuthCard'
import { EMAIL_IN_USE, registerWithPassword } from '@/lib/auth'
export function SignupPage() {
useLocale()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<I18nString | null>(null)
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await registerWithPassword(email, password, { redirectPath: '/app' })
await navigate({ to: '/app' })
} catch (err) {
setError(
err instanceof Error && err.message === EMAIL_IN_USE
? m.auth__signup__email_in_use()
: m.auth__signup__error(),
)
} finally {
setBusy(false)
}
}
return (
<AuthCard
title={m.auth__signup__title()}
description={m.auth__signup__description()}
cta={m.auth__signup__cta()}
email={email}
password={password}
passwordAutoComplete="new-password"
busy={busy}
message={null}
error={error}
onEmailChange={setEmail}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
footer={
<>
{m.auth__signup__footer_prompt()}{' '}
<Anchor component={Link} to="/login">
{m.auth__signup__footer_action()}
</Anchor>
</>
}
/>
)
}

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

@@ -0,0 +1,20 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
// 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',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } 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 { activeTheme, buildTheme, themes, type Palette } from '@project/mantine-themes'
import { type MantineThemeOverride } from '@pikku/mantine/core'
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'
// 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() {
// Server-safe defaults; useEffect syncs from localStorage after hydration.
const [themeId, setThemeIdRaw] = useState('default')
const [locale, setLocaleRaw] = useState(defaultLocale)
// Transient override from fabric console live-preview (not persisted).
const [previewTheme, setPreviewTheme] = useState<MantineThemeOverride | null>(null)
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
// Sync preferences from localStorage after hydration.
useEffect(() => {
const savedTheme = localStorage.getItem(THEME_KEY)
if (savedTheme && themes[savedTheme]) setThemeIdRaw(savedTheme)
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 overrides the active theme
// without persisting, so the user sees the palette instantly in an iframe.
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
try {
setPreviewTheme(data.palette ? buildTheme(data.palette as Palette) : 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)
setThemeIdRaw(next)
// Clear any live-preview override so the persisted theme takes effect.
setPreviewTheme(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="dark" />
</head>
<body>
<MantineProvider theme={effectiveTheme} defaultColorScheme="dark">
<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 { MessagePage } from '@/pages/MessagePage'
export const Route = createFileRoute('/app/')({
component: MessagePage,
})

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,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { LandingPage } from '@/pages/LandingPage'
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
export const Route = createFileRoute('/')({
component: LandingRoute,
})
function LandingRoute() {
useRedirectIfAuthenticated()
return <LandingPage />
}

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"]
}

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

@@ -0,0 +1,29 @@
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,
},
})

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"],
}