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