chore: starter template

This commit is contained in:
e2e
2026-07-10 22:25:02 +02:00
commit 33da14f99e
157 changed files with 6462 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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