chore: server-and-serverless template
This commit is contained in:
103
apps/app/src/components/AppShell.tsx
Normal file
103
apps/app/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
AppShell as MantineAppShell,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
NavLink,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Link, Outlet } from '@tanstack/react-router'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { signOut } from '@/lib/auth'
|
||||
import { LanguageSelector } from './LanguageSelector'
|
||||
import { ThemeSelector } from './ThemeSelector'
|
||||
|
||||
export function AppShell() {
|
||||
useLocale()
|
||||
const session = usePikkuQuery('getSession', {})
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
return (
|
||||
<MantineAppShell
|
||||
header={{ height: 88 }}
|
||||
navbar={{ width: 250, breakpoint: 'sm' }}
|
||||
padding="lg"
|
||||
styles={{
|
||||
main: {
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(96, 165, 250, 0.12), transparent 26%), linear-gradient(180deg, #10203a 0%, #07111e 56%, #040913 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MantineAppShell.Header px="lg" py="md">
|
||||
<Group justify="space-between" align="center" h="100%">
|
||||
<div>
|
||||
<Text tt="uppercase" fw={700} fz="xs" c="blue.3" style={{ letterSpacing: '0.18em' }}>
|
||||
{m.app_shell__eyebrow()}
|
||||
</Text>
|
||||
<Title order={2}>{m.app_shell__title()}</Title>
|
||||
</div>
|
||||
<Group gap="md">
|
||||
<LanguageSelector />
|
||||
<ThemeSelector />
|
||||
<Box
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-dark-4)',
|
||||
borderRadius: '1rem',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{session.data?.name ? asI18n(session.data.name) : m.app_shell__signed_in()}
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{session.data?.email ? asI18n(session.data.email) : m.common__loading()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={isSigningOut}
|
||||
onClick={async () => {
|
||||
setIsSigningOut(true)
|
||||
try {
|
||||
await signOut()
|
||||
window.location.href = '/login'
|
||||
} finally {
|
||||
setIsSigningOut(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{m.app_shell__sign_out()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</MantineAppShell.Header>
|
||||
|
||||
<MantineAppShell.Navbar p="md">
|
||||
<Stack gap="xs">
|
||||
<NavLink
|
||||
component={Link}
|
||||
to="/app"
|
||||
label={m.app_shell__nav__message()}
|
||||
description={m.app_shell__nav__message_description()}
|
||||
variant="filled"
|
||||
activeOptions={{ exact: true }}
|
||||
/>
|
||||
</Stack>
|
||||
</MantineAppShell.Navbar>
|
||||
|
||||
<MantineAppShell.Main>
|
||||
<Outlet />
|
||||
</MantineAppShell.Main>
|
||||
</MantineAppShell>
|
||||
)
|
||||
}
|
||||
105
apps/app/src/components/AuthCard.tsx
Normal file
105
apps/app/src/components/AuthCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import type { I18nNode, I18nString } from '@pikku/react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
type AuthCardProps = {
|
||||
title: I18nString
|
||||
description: I18nString
|
||||
cta: I18nString
|
||||
email: string
|
||||
password: string
|
||||
// 'current-password' on login (let the browser fill a saved password),
|
||||
// 'new-password' on signup (let it suggest a strong one).
|
||||
passwordAutoComplete: 'current-password' | 'new-password'
|
||||
busy: boolean
|
||||
message: I18nString | null
|
||||
error: I18nString | null
|
||||
footer: I18nNode
|
||||
onEmailChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
}
|
||||
|
||||
export function AuthCard(props: AuthCardProps) {
|
||||
useLocale()
|
||||
|
||||
return (
|
||||
<Box
|
||||
mih="100vh"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(96, 165, 250, 0.16), transparent 30%), linear-gradient(180deg, #10203a 0%, #07111e 56%, #040913 100%)',
|
||||
}}
|
||||
py="xl"
|
||||
>
|
||||
<Container size={560}>
|
||||
<Card radius="xl" padding="xl" withBorder shadow="xl" bg="rgba(10, 20, 36, 0.86)">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
c="blue.3"
|
||||
mb="sm"
|
||||
style={{ letterSpacing: '0.18em' }}
|
||||
>
|
||||
{m.auth__eyebrow()}
|
||||
</Text>
|
||||
<Title order={1}>{props.title}</Title>
|
||||
<Text c="dimmed" mt="sm" maw={440}>
|
||||
{props.description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={props.onSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.common__email()}
|
||||
placeholder={m.common__email_placeholder()}
|
||||
value={props.email}
|
||||
onChange={(event) => props.onEmailChange(event.currentTarget.value)}
|
||||
required
|
||||
radius="md"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<PasswordInput
|
||||
label={m.common__password()}
|
||||
placeholder={m.common__password_placeholder()}
|
||||
value={props.password}
|
||||
onChange={(event) => props.onPasswordChange(event.currentTarget.value)}
|
||||
required
|
||||
radius="md"
|
||||
autoComplete={props.passwordAutoComplete}
|
||||
/>
|
||||
<Button type="submit" loading={props.busy} radius="md" size="md">
|
||||
{props.cta}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{props.message ? <Text c="teal.3">{props.message}</Text> : null}
|
||||
{props.error ? <Text c="red.3">{props.error}</Text> : null}
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{props.footer}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
apps/app/src/components/MessageCard.tsx
Normal file
51
apps/app/src/components/MessageCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Card, Stack, Text, Title } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
type MessageCardProps = {
|
||||
message: string
|
||||
updatedAt: string
|
||||
updatedBy: {
|
||||
email: string
|
||||
name: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function MessageCard(props: MessageCardProps) {
|
||||
useLocale()
|
||||
const updatedBy = props.updatedBy?.name || props.updatedBy?.email || m.message__read__nobody()
|
||||
|
||||
return (
|
||||
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
c="blue.3"
|
||||
mb="sm"
|
||||
style={{ letterSpacing: '0.18em' }}
|
||||
>
|
||||
{m.message__read__eyebrow()}
|
||||
</Text>
|
||||
<Title order={2}>{m.message__read__title()}</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
{m.message__read__description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card radius="lg" padding="xl" bg="rgba(255,255,255,0.03)" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Title order={1} fz="2.35rem">
|
||||
{asI18n(props.message)}
|
||||
</Title>
|
||||
<Text c="dimmed">{m.message__read__last_updated_by({ name: updatedBy })}</Text>
|
||||
<Text c="dimmed">{asI18n(new Date(props.updatedAt).toLocaleString())}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
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 { palettes } from '@project/mantine-themes'
|
||||
import { usePreferences } from '@/contexts/preferences'
|
||||
|
||||
export function ThemeSelector() {
|
||||
useLocale()
|
||||
const { themeId, setThemeId } = usePreferences()
|
||||
|
||||
const data = Object.entries(palettes as Record<string, { name: string }>).map(([id, p]) => ({
|
||||
value: id,
|
||||
label: p.name,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={m.preferences__theme()}
|
||||
data={data}
|
||||
value={themeId}
|
||||
onChange={(v) => v && setThemeId(v)}
|
||||
size="xs"
|
||||
w={110}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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])
|
||||
}
|
||||
5
apps/app/src/hooks/useMessage.ts
Normal file
5
apps/app/src/hooks/useMessage.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
|
||||
export function useMessage() {
|
||||
return usePikkuQuery('getMessage', {})
|
||||
}
|
||||
14
apps/app/src/hooks/useUpdateMessage.ts
Normal file
14
apps/app/src/hooks/useUpdateMessage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
|
||||
|
||||
export function useUpdateMessage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return usePikkuMutation('updateMessage', {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['getMessage', {}],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
79
apps/app/src/i18n/config.ts
Normal file
79
apps/app/src/i18n/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
|
||||
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
|
||||
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
|
||||
// message functions (`m`).
|
||||
//
|
||||
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
|
||||
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
|
||||
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { locales, baseLocale, overwriteGetLocale } from '../paraglide/runtime.js'
|
||||
|
||||
export const supportedLocales = locales
|
||||
export const defaultLocale = baseLocale
|
||||
export type Locale = (typeof locales)[number]
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
|
||||
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
export function detectLocale(pathname: string): Locale {
|
||||
const segment = pathname.split('/')[1]
|
||||
if (supportedLocales.includes(segment as Locale)) return segment as Locale
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const browserLang = navigator.language?.split('-')[0]
|
||||
if (supportedLocales.includes(browserLang as Locale)) return browserLang as Locale
|
||||
}
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
// ── reactive locale store ────────────────────────────────────────────────────
|
||||
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
|
||||
// it to a tiny external store: `getLocale` reads `activeLocale`; components
|
||||
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
|
||||
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
|
||||
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
|
||||
let activeLocale: Locale = defaultLocale
|
||||
const listeners = new Set<() => void>()
|
||||
overwriteGetLocale(() => activeLocale)
|
||||
|
||||
export function setActiveLocale(next: Locale): void {
|
||||
if (next === activeLocale) return
|
||||
activeLocale = next
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
|
||||
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
|
||||
// used to live; it also returns the active locale + direction for components
|
||||
// that need them (e.g. the language switcher).
|
||||
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
|
||||
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
|
||||
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
|
||||
}
|
||||
|
||||
// ── i18n-debug masking ───────────────────────────────────────────────────────
|
||||
// When enabled, every *translated* string is masked to block glyphs (█) so any
|
||||
// readable text left on screen is text that never went through a message (a
|
||||
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
|
||||
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
|
||||
export function isI18nDebug(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||
if (typeof window === 'undefined') return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||
}
|
||||
|
||||
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
|
||||
export function maskI18n(s: string): string {
|
||||
return isI18nDebug() ? s.replace(/\S/g, '█') : s
|
||||
}
|
||||
24
apps/app/src/i18n/messages.ts
Normal file
24
apps/app/src/i18n/messages.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Typed message functions for the app. `m.landing_title()` returns a branded
|
||||
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
|
||||
// `t('landing.title')` used to — no call-site boilerplate.
|
||||
//
|
||||
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
|
||||
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
|
||||
// per-message tree-shaking — fine here: locale files are KB and the app bundled
|
||||
// all of en.json under i18next anyway.
|
||||
import { m as _m } from '../paraglide/messages.js'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { maskI18n } from './config.js'
|
||||
|
||||
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
|
||||
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
|
||||
|
||||
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
|
||||
const _wrapped: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(_raw)) {
|
||||
const fn = _raw[key]
|
||||
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
|
||||
}
|
||||
|
||||
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
|
||||
export const m = _wrapped as unknown as Branded<typeof _m>
|
||||
85
apps/app/src/lib/auth.ts
Normal file
85
apps/app/src/lib/auth.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { apiUrl } from './env'
|
||||
|
||||
// Better Auth browser client. It targets the worker's catch-all `/api/auth/**`
|
||||
// routes (generated from src/auth.ts in the functions package). Pass the FULL
|
||||
// auth base (`<apiUrl>/auth`): better-auth's withPath only appends its default
|
||||
// `/api/auth` when baseURL has no path, and apiUrl() already carries `/api`, so
|
||||
// a bare apiUrl() would leave the client calling `/api/get-session` (404) and
|
||||
// the app would loop back to /login. Cookies ride every request so the session
|
||||
// round-trips across origins.
|
||||
export const authClient = createAuthClient({ baseURL: `${apiUrl()}/auth` })
|
||||
|
||||
export type AuthSession = {
|
||||
user?: {
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
} | null
|
||||
expires?: string
|
||||
}
|
||||
|
||||
// Thrown by signInWithPassword when the email/password pair is wrong.
|
||||
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||
// Thrown by registerWithPassword when the email is already taken.
|
||||
export const EMAIL_IN_USE = 'EMAIL_IN_USE'
|
||||
|
||||
export async function getAuthSession(): Promise<AuthSession> {
|
||||
const { data } = await authClient.getSession()
|
||||
if (!data?.user) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
email: data.user.email,
|
||||
name: data.user.name ?? null,
|
||||
image: data.user.image ?? null,
|
||||
},
|
||||
expires: data.session?.expiresAt
|
||||
? new Date(data.session.expiresAt).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with an email and password. On success Better Auth sets the session
|
||||
// cookie; on bad credentials this throws INVALID_CREDENTIALS.
|
||||
export async function signInWithPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
_redirectPath = '/app',
|
||||
) {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) {
|
||||
throw new Error(INVALID_CREDENTIALS)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new account. Better Auth signs the user in on success (sets the
|
||||
// session cookie), so they land logged in.
|
||||
export async function registerWithPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { name?: string; redirectPath?: string } = {},
|
||||
) {
|
||||
const { error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
// Better Auth requires a name; fall back to the local-part of the email.
|
||||
name: options.name?.trim() || email.split('@')[0],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// Better Auth returns 422 (UNPROCESSABLE_ENTITY) when the email is taken.
|
||||
if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) {
|
||||
throw new Error(EMAIL_IN_USE)
|
||||
}
|
||||
throw new Error('Unable to create account')
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
const { error } = await authClient.signOut()
|
||||
if (error) {
|
||||
throw new Error('Unable to sign out')
|
||||
}
|
||||
}
|
||||
18
apps/app/src/lib/env.ts
Normal file
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')
|
||||
}
|
||||
76
apps/app/src/pages/LandingPage.tsx
Normal file
76
apps/app/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button, Card, Container, Grid, Group, Stack, Text, Title } from '@pikku/mantine/core'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { m } from '@/i18n/messages'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
type CardKey = 'frontend' | 'backend' | 'auth'
|
||||
|
||||
const CARD_LABELS: Record<CardKey, { title: () => I18nString; description: () => I18nString }> = {
|
||||
frontend: { title: m.landing__cards__frontend__title, description: m.landing__cards__frontend__description },
|
||||
backend: { title: m.landing__cards__backend__title, description: m.landing__cards__backend__description },
|
||||
auth: { title: m.landing__cards__auth__title, description: m.landing__cards__auth__description },
|
||||
}
|
||||
|
||||
export function LandingPage() {
|
||||
useLocale()
|
||||
|
||||
const cards = ['frontend', 'backend', 'auth'] as const
|
||||
|
||||
return (
|
||||
<Container
|
||||
size="lg"
|
||||
py={72}
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Grid gap="xl" align="center">
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Text
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
c="blue.3"
|
||||
mb="sm"
|
||||
style={{ letterSpacing: '0.18em' }}
|
||||
>
|
||||
{m.landing__eyebrow()}
|
||||
</Text>
|
||||
<Title order={1} fz={{ base: '2.8rem', md: '4.5rem' }} lh={0.95}>
|
||||
{m.landing__title()}
|
||||
</Title>
|
||||
<Text c="dimmed" mt="lg" maw={620} size="lg">
|
||||
{m.landing__subtitle()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group>
|
||||
<Button component={Link} to="/signup" radius="md" size="md">
|
||||
{m.landing__create_account()}
|
||||
</Button>
|
||||
<Button component="a" href="/login" variant="light" radius="md" size="md">
|
||||
{m.landing__sign_in()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack gap="md">
|
||||
{cards.map((key) => (
|
||||
<Card key={key} radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
|
||||
<Text fw={700}>{CARD_LABELS[key].title()}</Text>
|
||||
<Text c="dimmed" mt="xs">
|
||||
{CARD_LABELS[key].description()}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
61
apps/app/src/pages/LoginPage.tsx
Normal file
61
apps/app/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Anchor } from '@pikku/mantine/core'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { AuthCard } from '@/components/AuthCard'
|
||||
import { INVALID_CREDENTIALS, signInWithPassword } from '@/lib/auth'
|
||||
|
||||
export function LoginPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<I18nString | null>(null)
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await signInWithPassword(email, password, '/app')
|
||||
await navigate({ to: '/app' })
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message === INVALID_CREDENTIALS
|
||||
? m.auth__login__invalid_credentials()
|
||||
: m.auth__login__error(),
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={m.auth__login__title()}
|
||||
description={m.auth__login__description()}
|
||||
cta={m.auth__login__cta()}
|
||||
email={email}
|
||||
password={password}
|
||||
passwordAutoComplete="current-password"
|
||||
busy={busy}
|
||||
message={null}
|
||||
error={error}
|
||||
onEmailChange={setEmail}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
footer={
|
||||
<>
|
||||
{m.auth__login__footer_prompt()}{' '}
|
||||
<Anchor component={Link} to="/signup">
|
||||
{m.auth__login__footer_action()}
|
||||
</Anchor>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
88
apps/app/src/pages/MessagePage.tsx
Normal file
88
apps/app/src/pages/MessagePage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Button, Card, Grid, Stack, Text, Textarea, Title } from '@pikku/mantine/core'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { MessageCard } from '@/components/MessageCard'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { useUpdateMessage } from '@/hooks/useUpdateMessage'
|
||||
|
||||
export function MessagePage() {
|
||||
useLocale()
|
||||
const messageQuery = useMessage()
|
||||
const updateMessage = useUpdateMessage()
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
await updateMessage.mutateAsync({ message })
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
if (messageQuery.isLoading) {
|
||||
return (
|
||||
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
|
||||
<Text c="dimmed">{m.message__loading()}</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (messageQuery.error || !messageQuery.data) {
|
||||
return (
|
||||
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
|
||||
<Text c="red.3">{m.message__load_error()}</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<MessageCard
|
||||
message={messageQuery.data.message}
|
||||
updatedAt={messageQuery.data.updatedAt}
|
||||
updatedBy={messageQuery.data.updatedBy}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card radius="xl" padding="xl" withBorder bg="rgba(10, 20, 36, 0.82)">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
c="blue.3"
|
||||
mb="sm"
|
||||
style={{ letterSpacing: '0.18em' }}
|
||||
>
|
||||
{m.message__update__eyebrow()}
|
||||
</Text>
|
||||
<Title order={2}>{m.message__update__title()}</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
{m.message__update__description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label={m.message__update__field_label()}
|
||||
rows={4}
|
||||
placeholder={m.message__update__placeholder()}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<Button type="submit" loading={updateMessage.isPending} radius="md">
|
||||
{m.message__update__cta()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
61
apps/app/src/pages/SignupPage.tsx
Normal file
61
apps/app/src/pages/SignupPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Anchor } from '@pikku/mantine/core'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { AuthCard } from '@/components/AuthCard'
|
||||
import { EMAIL_IN_USE, registerWithPassword } from '@/lib/auth'
|
||||
|
||||
export function SignupPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<I18nString | null>(null)
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await registerWithPassword(email, password, { redirectPath: '/app' })
|
||||
await navigate({ to: '/app' })
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message === EMAIL_IN_USE
|
||||
? m.auth__signup__email_in_use()
|
||||
: m.auth__signup__error(),
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={m.auth__signup__title()}
|
||||
description={m.auth__signup__description()}
|
||||
cta={m.auth__signup__cta()}
|
||||
email={email}
|
||||
password={password}
|
||||
passwordAutoComplete="new-password"
|
||||
busy={busy}
|
||||
message={null}
|
||||
error={error}
|
||||
onEmailChange={setEmail}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
footer={
|
||||
<>
|
||||
{m.auth__signup__footer_prompt()}{' '}
|
||||
<Anchor component={Link} to="/login">
|
||||
{m.auth__signup__footer_action()}
|
||||
</Anchor>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
20
apps/app/src/router.tsx
Normal file
20
apps/app/src/router.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
// TanStack Start discovers this `getRouter` factory to build a router per
|
||||
// request (server) and once on the client (hydration). routeTree.gen.ts is
|
||||
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
|
||||
// hand-edit it.
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
118
apps/app/src/routes/__root.tsx
Normal file
118
apps/app/src/routes/__root.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
|
||||
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@pikku/mantine/core'
|
||||
import '@mantine/core/styles.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { PikkuProvider, createPikku } from '@pikku/react'
|
||||
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
|
||||
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
|
||||
import { activeTheme, buildTheme, themes, type Palette } from '@project/mantine-themes'
|
||||
import { type MantineThemeOverride } from '@pikku/mantine/core'
|
||||
import { defaultLocale, localeDir, supportedLocales, setActiveLocale } from '@/i18n/config'
|
||||
import { apiUrl } from '@/lib/env'
|
||||
import { PreferencesContext } from '@/contexts/preferences'
|
||||
|
||||
const LOCALE_KEY = 'app-locale'
|
||||
const THEME_KEY = 'app-theme'
|
||||
|
||||
// Root route owns the full HTML document for SSR (<html lang dir> so the tree
|
||||
// mirrors for RTL locales) and wraps the app in the Mantine + react-query +
|
||||
// Pikku providers. Mantine v8 SSR needs ColorSchemeScript in <head> and
|
||||
// mantineHtmlProps on <html>; @mantine/core/styles.css ships the static CSS.
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'Pikku App' },
|
||||
],
|
||||
}),
|
||||
component: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument() {
|
||||
// Server-safe defaults; useEffect syncs from localStorage after hydration.
|
||||
const [themeId, setThemeIdRaw] = useState('default')
|
||||
const [locale, setLocaleRaw] = useState(defaultLocale)
|
||||
// Transient override from fabric console live-preview (not persisted).
|
||||
const [previewTheme, setPreviewTheme] = useState<MantineThemeOverride | null>(null)
|
||||
|
||||
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
|
||||
|
||||
// Sync preferences from localStorage after hydration.
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem(THEME_KEY)
|
||||
if (savedTheme && themes[savedTheme]) setThemeIdRaw(savedTheme)
|
||||
|
||||
const savedLocale = localStorage.getItem(LOCALE_KEY)
|
||||
if (savedLocale && supportedLocales.includes(savedLocale as (typeof supportedLocales)[number])) {
|
||||
setLocaleRaw(savedLocale)
|
||||
setActiveLocale(savedLocale as (typeof supportedLocales)[number])
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep <html lang dir> in sync with locale changes after hydration.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale
|
||||
document.documentElement.dir = localeDir(locale)
|
||||
}, [locale])
|
||||
|
||||
// Fabric console live-preview: postMessage overrides the active theme
|
||||
// without persisting, so the user sees the palette instantly in an iframe.
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data
|
||||
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
|
||||
try {
|
||||
setPreviewTheme(data.palette ? buildTheme(data.palette as Palette) : null)
|
||||
} catch (err) {
|
||||
console.warn('[mantine-themes] ignoring bad theme payload', err)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [])
|
||||
|
||||
const setLocale = (next: string) => {
|
||||
localStorage.setItem(LOCALE_KEY, next)
|
||||
setLocaleRaw(next)
|
||||
setActiveLocale(next as (typeof supportedLocales)[number])
|
||||
}
|
||||
|
||||
const setThemeId = (next: string) => {
|
||||
localStorage.setItem(THEME_KEY, next)
|
||||
setThemeIdRaw(next)
|
||||
// Clear any live-preview override so the persisted theme takes effect.
|
||||
setPreviewTheme(null)
|
||||
}
|
||||
|
||||
// Per-render instances keep server requests from sharing client/cache state.
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
const [pikku] = useState(() =>
|
||||
createPikku(PikkuFetch, PikkuRPC, {
|
||||
serverUrl: apiUrl(),
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={localeDir(locale)} {...mantineHtmlProps}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<ColorSchemeScript defaultColorScheme="dark" />
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider theme={effectiveTheme} defaultColorScheme="dark">
|
||||
<PreferencesContext.Provider value={{ locale, themeId, setLocale, setThemeId }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PikkuProvider pikku={pikku}>
|
||||
<Outlet />
|
||||
</PikkuProvider>
|
||||
</QueryClientProvider>
|
||||
</PreferencesContext.Provider>
|
||||
</MantineProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
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 { MessagePage } from '@/pages/MessagePage'
|
||||
|
||||
export const Route = createFileRoute('/app/')({
|
||||
component: MessagePage,
|
||||
})
|
||||
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 />
|
||||
}
|
||||
12
apps/app/src/routes/index.tsx
Normal file
12
apps/app/src/routes/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LandingPage } from '@/pages/LandingPage'
|
||||
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: LandingRoute,
|
||||
})
|
||||
|
||||
function LandingRoute() {
|
||||
useRedirectIfAuthenticated()
|
||||
return <LandingPage />
|
||||
}
|
||||
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 />
|
||||
}
|
||||
Reference in New Issue
Block a user