chore: starter template
This commit is contained in:
42
apps/app/messages/en.json
Normal file
42
apps/app/messages/en.json
Normal 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
36
apps/app/messages/fr.json
Normal 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
42
apps/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
apps/app/project.inlang/settings.json
Normal file
9
apps/app/project.inlang/settings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
90
apps/app/src/components/AppShell.tsx
Normal file
90
apps/app/src/components/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
apps/app/src/components/AuthCard.tsx
Normal file
227
apps/app/src/components/AuthCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/app/src/components/DefaultErrorPage.tsx
Normal file
97
apps/app/src/components/DefaultErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
apps/app/src/components/LanguageSelector.tsx
Normal file
32
apps/app/src/components/LanguageSelector.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
27
apps/app/src/components/ThemeSelector.tsx
Normal file
27
apps/app/src/components/ThemeSelector.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
apps/app/src/components/Wordmark.tsx
Normal file
39
apps/app/src/components/Wordmark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
apps/app/src/contexts/preferences.tsx
Normal file
19
apps/app/src/contexts/preferences.tsx
Normal 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)
|
||||
}
|
||||
46
apps/app/src/hooks/useAuthGate.ts
Normal file
46
apps/app/src/hooks/useAuthGate.ts
Normal 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])
|
||||
}
|
||||
87
apps/app/src/i18n/config.ts
Normal file
87
apps/app/src/i18n/config.ts
Normal 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
|
||||
}
|
||||
33
apps/app/src/i18n/messages.ts
Normal file
33
apps/app/src/i18n/messages.ts
Normal 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
133
apps/app/src/lib/auth.ts
Normal 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
18
apps/app/src/lib/env.ts
Normal 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'
|
||||
}
|
||||
22
apps/app/src/pages/HomePage.tsx
Normal file
22
apps/app/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
apps/app/src/pages/LoginPage.tsx
Normal file
78
apps/app/src/pages/LoginPage.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
apps/app/src/pages/SignupPage.tsx
Normal file
56
apps/app/src/pages/SignupPage.tsx
Normal 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
22
apps/app/src/router.tsx
Normal 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>
|
||||
}
|
||||
}
|
||||
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 />
|
||||
}
|
||||
23
apps/app/tsconfig.json
Normal file
23
apps/app/tsconfig.json
Normal 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
59
apps/app/vite.config.ts
Normal 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
9
apps/app/wrangler.jsonc
Normal 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"],
|
||||
}
|
||||
Reference in New Issue
Block a user