commit dced26963762f6a21a8955daf13c4231bd376a35 Author: e2e Date: Sun Jun 28 15:09:09 2026 +0200 chore: server-and-serverless template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f65e962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules +dist +.vite +.wrangler + +# Generated by @pikku/cli — regenerated on `pikku all`. +# Ignore the contents (not the directory) so negation below can whitelist +# the `api.gen.ts` stub that keeps the skeleton compilable pre-`pikku all`. +packages/functions/.pikku/client/* +!packages/functions/.pikku/client/api.gen.ts + +# Scaffold wirings are generated by `pikku all` — never commit them. +packages/functions/src/scaffold/*.gen.ts +packages/functions/src/scaffold/*.gen.js + +# Yarn — commit the root lockfile; ignore PnP/cache and generated per-unit lockfiles +.yarn/ +.pnp.* +yarn.lock +!/yarn.lock + +# Vendored packages injected at sandbox runtime — never commit +vendor/ diff --git a/.pikku-runtime/dev.db b/.pikku-runtime/dev.db new file mode 100644 index 0000000..c920189 Binary files /dev/null and b/.pikku-runtime/dev.db differ diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..2d9f6b2 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,4 @@ +# Force node_modules linker — some toolchain deps (uWebSockets.js native +# addon, pikku CLI bin, esbuild native) resolve cleanly only with a +# traditional tree. +nodeLinker: node-modules diff --git a/apps/app/messages/en.json b/apps/app/messages/en.json new file mode 100644 index 0000000..a91fd90 --- /dev/null +++ b/apps/app/messages/en.json @@ -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" +} diff --git a/apps/app/messages/fr.json b/apps/app/messages/fr.json new file mode 100644 index 0000000..8b43003 --- /dev/null +++ b/apps/app/messages/fr.json @@ -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" +} diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 0000000..64e91c9 --- /dev/null +++ b/apps/app/package.json @@ -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" + } +} diff --git a/apps/app/project.inlang/settings.json b/apps/app/project.inlang/settings.json new file mode 100644 index 0000000..aabca8f --- /dev/null +++ b/apps/app/project.inlang/settings.json @@ -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" + } +} diff --git a/apps/app/src/components/AppShell.tsx b/apps/app/src/components/AppShell.tsx new file mode 100644 index 0000000..154d640 --- /dev/null +++ b/apps/app/src/components/AppShell.tsx @@ -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 ( + + + +
+ + {m.app_shell__eyebrow()} + + {m.app_shell__title()} +
+ + + + + + {session.data?.name ? asI18n(session.data.name) : m.app_shell__signed_in()} + + + {session.data?.email ? asI18n(session.data.email) : m.common__loading()} + + + + +
+
+ + + + + + + + + + +
+ ) +} diff --git a/apps/app/src/components/AuthCard.tsx b/apps/app/src/components/AuthCard.tsx new file mode 100644 index 0000000..f43bcd4 --- /dev/null +++ b/apps/app/src/components/AuthCard.tsx @@ -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) => void +} + +export function AuthCard(props: AuthCardProps) { + useLocale() + + return ( + + + + +
+ + {m.auth__eyebrow()} + + {props.title} + + {props.description} + +
+ +
+ + props.onEmailChange(event.currentTarget.value)} + required + radius="md" + type="email" + autoComplete="email" + /> + props.onPasswordChange(event.currentTarget.value)} + required + radius="md" + autoComplete={props.passwordAutoComplete} + /> + + +
+ + {props.message ? {props.message} : null} + {props.error ? {props.error} : null} + + + {props.footer} + +
+
+
+
+ ) +} diff --git a/apps/app/src/components/LanguageSelector.tsx b/apps/app/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..b998e3a --- /dev/null +++ b/apps/app/src/components/LanguageSelector.tsx @@ -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 = { + 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 ( + v && setThemeId(v)} + size="xs" + w={110} + allowDeselect={false} + /> + ) +} diff --git a/apps/app/src/contexts/preferences.tsx b/apps/app/src/contexts/preferences.tsx new file mode 100644 index 0000000..06da6a2 --- /dev/null +++ b/apps/app/src/contexts/preferences.tsx @@ -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({ + locale: 'en', + themeId: 'default', + setLocale: () => {}, + setThemeId: () => {}, +}) + +export function usePreferences(): PreferencesContextValue { + return useContext(PreferencesContext) +} diff --git a/apps/app/src/hooks/useAuthGate.ts b/apps/app/src/hooks/useAuthGate.ts new file mode 100644 index 0000000..5e95804 --- /dev/null +++ b/apps/app/src/hooks/useAuthGate.ts @@ -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=.) +// 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]) +} diff --git a/apps/app/src/hooks/useMessage.ts b/apps/app/src/hooks/useMessage.ts new file mode 100644 index 0000000..d369096 --- /dev/null +++ b/apps/app/src/hooks/useMessage.ts @@ -0,0 +1,5 @@ +import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen' + +export function useMessage() { + return usePikkuQuery('getMessage', {}) +} diff --git a/apps/app/src/hooks/useUpdateMessage.ts b/apps/app/src/hooks/useUpdateMessage.ts new file mode 100644 index 0000000..a299639 --- /dev/null +++ b/apps/app/src/hooks/useUpdateMessage.ts @@ -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', {}], + }) + }, + }) +} diff --git a/apps/app/src/i18n/config.ts b/apps/app/src/i18n/config.ts new file mode 100644 index 0000000..5adbbbe --- /dev/null +++ b/apps/app/src/i18n/config.ts @@ -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/.json` next to `en.json`, add the code to +// `project.inlang/settings.json` `locales`, and recompile. Content is reachable +// via the `/` 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 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 + ) 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(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 +} diff --git a/apps/app/src/i18n/messages.ts b/apps/app/src/i18n/messages.ts new file mode 100644 index 0000000..581398a --- /dev/null +++ b/apps/app/src/i18n/messages.ts @@ -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 extends (...args: infer A) => unknown ? (...args: A) => I18nString : T +type Branded = { [K in keyof T]: BrandReturn } + +const _raw = _m as unknown as Record) => string> +const _wrapped: Record = {} +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 diff --git a/apps/app/src/lib/auth.ts b/apps/app/src/lib/auth.ts new file mode 100644 index 0000000..7f34341 --- /dev/null +++ b/apps/app/src/lib/auth.ts @@ -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 (`/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 { + 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') + } +} diff --git a/apps/app/src/lib/env.ts b/apps/app/src/lib/env.ts new file mode 100644 index 0000000..4a5f628 --- /dev/null +++ b/apps/app/src/lib/env.ts @@ -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') +} diff --git a/apps/app/src/pages/LandingPage.tsx b/apps/app/src/pages/LandingPage.tsx new file mode 100644 index 0000000..b9cec95 --- /dev/null +++ b/apps/app/src/pages/LandingPage.tsx @@ -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 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 ( + + + + +
+ + {m.landing__eyebrow()} + + + {m.landing__title()} + + + {m.landing__subtitle()} + +
+ + + + + +
+
+ + + + {cards.map((key) => ( + + {CARD_LABELS[key].title()} + + {CARD_LABELS[key].description()} + + + ))} + + +
+
+ ) +} diff --git a/apps/app/src/pages/LoginPage.tsx b/apps/app/src/pages/LoginPage.tsx new file mode 100644 index 0000000..793b32c --- /dev/null +++ b/apps/app/src/pages/LoginPage.tsx @@ -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(null) + + const handleSubmit = async (event: FormEvent) => { + 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 ( + + {m.auth__login__footer_prompt()}{' '} + + {m.auth__login__footer_action()} + + + } + /> + ) +} diff --git a/apps/app/src/pages/MessagePage.tsx b/apps/app/src/pages/MessagePage.tsx new file mode 100644 index 0000000..f2e5a54 --- /dev/null +++ b/apps/app/src/pages/MessagePage.tsx @@ -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) => { + event.preventDefault() + await updateMessage.mutateAsync({ message }) + setMessage('') + } + + if (messageQuery.isLoading) { + return ( + + {m.message__loading()} + + ) + } + + if (messageQuery.error || !messageQuery.data) { + return ( + + {m.message__load_error()} + + ) + } + + return ( + + + + + + + + +
+ + {m.message__update__eyebrow()} + + {m.message__update__title()} + + {m.message__update__description()} + +
+ +
+ +