chore: starter template
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user