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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user