Initial commit: full-stack Bun + Elysia + React template
Elysia.js API with session-based auth (email/password + Google OAuth), role system (USER/ADMIN/SUPER_ADMIN), Prisma + PostgreSQL, React 19 with Mantine UI, TanStack Router, dark theme, and comprehensive test suite (unit, integration, E2E with Lightpanda). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
40
src/frontend/App.tsx
Normal file
40
src/frontend/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
|
||||
import '@mantine/core/styles.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRouter, RouterProvider } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: 'blue',
|
||||
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<ColorSchemeScript defaultColorScheme="dark" />
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark" forceColorScheme="dark">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
157
src/frontend/DevInspector.tsx
Normal file
157
src/frontend/DevInspector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface CodeInfo {
|
||||
relativePath: string
|
||||
line: string
|
||||
column: string
|
||||
}
|
||||
|
||||
/** Walk up DOM tree, cari elemen dengan data-inspector-* attributes */
|
||||
function findCodeInfo(target: HTMLElement): { element: HTMLElement; info: CodeInfo } | null {
|
||||
let el: HTMLElement | null = target
|
||||
while (el) {
|
||||
const relativePath = el.getAttribute('data-inspector-relative-path')
|
||||
const line = el.getAttribute('data-inspector-line')
|
||||
const column = el.getAttribute('data-inspector-column')
|
||||
if (relativePath && line) {
|
||||
return {
|
||||
element: el,
|
||||
info: { relativePath, line, column: column ?? '1' },
|
||||
}
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openInEditor(info: CodeInfo) {
|
||||
fetch('/__open-in-editor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
relativePath: info.relativePath,
|
||||
lineNumber: info.line,
|
||||
columnNumber: info.column,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function DevInspector({ children }: { children: React.ReactNode }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastInfoRef = useRef<CodeInfo | null>(null)
|
||||
|
||||
const updateOverlay = useCallback((target: HTMLElement | null) => {
|
||||
const ov = overlayRef.current
|
||||
const tt = tooltipRef.current
|
||||
if (!ov || !tt) return
|
||||
|
||||
if (!target) {
|
||||
ov.style.display = 'none'
|
||||
tt.style.display = 'none'
|
||||
lastInfoRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const result = findCodeInfo(target)
|
||||
if (!result) {
|
||||
ov.style.display = 'none'
|
||||
tt.style.display = 'none'
|
||||
lastInfoRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
lastInfoRef.current = result.info
|
||||
const rect = result.element.getBoundingClientRect()
|
||||
ov.style.display = 'block'
|
||||
ov.style.top = `${rect.top + window.scrollY}px`
|
||||
ov.style.left = `${rect.left + window.scrollX}px`
|
||||
ov.style.width = `${rect.width}px`
|
||||
ov.style.height = `${rect.height}px`
|
||||
|
||||
tt.style.display = 'block'
|
||||
tt.textContent = `${result.info.relativePath}:${result.info.line}`
|
||||
const ttTop = rect.top + window.scrollY - 24
|
||||
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`
|
||||
tt.style.left = `${rect.left + window.scrollX}px`
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement)
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const result = findCodeInfo(e.target as HTMLElement)
|
||||
const info = result?.info ?? lastInfoRef.current
|
||||
if (info) {
|
||||
const loc = `${info.relativePath}:${info.line}:${info.column}`
|
||||
navigator.clipboard.writeText(loc).catch(() => {})
|
||||
openInEditor(info)
|
||||
}
|
||||
setActive(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setActive(false)
|
||||
}
|
||||
document.addEventListener('mouseover', onMouseOver, true)
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.body.style.cursor = 'crosshair'
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onMouseOver, true)
|
||||
document.removeEventListener('click', onClick, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.cursor = ''
|
||||
if (overlayRef.current) overlayRef.current.style.display = 'none'
|
||||
if (tooltipRef.current) tooltipRef.current.style.display = 'none'
|
||||
}
|
||||
}, [active, updateOverlay])
|
||||
|
||||
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C (Windows/Linux)
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) {
|
||||
e.preventDefault()
|
||||
setActive((prev) => !prev)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
border: '2px solid #3b82f6',
|
||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||
zIndex: 99999,
|
||||
transition: 'all 0.05s ease',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
zIndex: 100000,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
src/frontend/components/ErrorPage.tsx
Normal file
26
src/frontend/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button, Center, Code, Stack, Text, Title } from '@mantine/core'
|
||||
import { TbAlertTriangle, TbRefresh } from 'react-icons/tb'
|
||||
|
||||
export function ErrorPage({ error }: { error: unknown }) {
|
||||
const message = error instanceof Error ? error.message : 'Terjadi kesalahan yang tidak terduga'
|
||||
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Stack align="center" gap="md" maw={500}>
|
||||
<TbAlertTriangle size={80} color="var(--mantine-color-red-6)" />
|
||||
<Title order={1}>500</Title>
|
||||
<Text c="dimmed" size="lg" ta="center">Terjadi kesalahan pada server</Text>
|
||||
<Code block c="red" style={{ maxWidth: '100%', wordBreak: 'break-word' }}>
|
||||
{message}
|
||||
</Code>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
leftSection={<TbRefresh size={18} />}
|
||||
variant="light"
|
||||
>
|
||||
Muat Ulang
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
18
src/frontend/components/NotFound.tsx
Normal file
18
src/frontend/components/NotFound.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { TbArrowLeft, TbError404 } from 'react-icons/tb'
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Stack align="center" gap="md">
|
||||
<TbError404 size={80} color="var(--mantine-color-dimmed)" />
|
||||
<Title order={1}>404</Title>
|
||||
<Text c="dimmed" size="lg">Halaman tidak ditemukan</Text>
|
||||
<Button component={Link} to="/" leftSection={<TbArrowLeft size={18} />} variant="light">
|
||||
Kembali ke Beranda
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
66
src/frontend/hooks/useAuth.ts
Normal file
66
src/frontend/hooks/useAuth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, { credentials: 'include', ...init })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Request failed' }))
|
||||
throw new Error(err.error || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'session'],
|
||||
queryFn: () => apiFetch<{ user: User | null }>('/api/auth/session'),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { email: string; password: string }) =>
|
||||
apiFetch<{ user: User }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'session'], data)
|
||||
// Super admin → dashboard, others → profile
|
||||
if (data.user.role === 'SUPER_ADMIN') {
|
||||
navigate({ to: '/dashboard' })
|
||||
} else {
|
||||
navigate({ to: '/profile' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(['auth', 'session'], { user: null })
|
||||
navigate({ to: '/login' })
|
||||
},
|
||||
})
|
||||
}
|
||||
14
src/frontend/routes/__root.tsx
Normal file
14
src/frontend/routes/__root.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { NotFound } from '@/frontend/components/NotFound'
|
||||
import { ErrorPage } from '@/frontend/components/ErrorPage'
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => <Outlet />,
|
||||
notFoundComponent: NotFound,
|
||||
errorComponent: ({ error }) => <ErrorPage error={error} />,
|
||||
})
|
||||
94
src/frontend/routes/dashboard.tsx
Normal file
94
src/frontend/routes/dashboard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
try {
|
||||
const data = await context.queryClient.ensureQueryData({
|
||||
queryKey: ['auth', 'session'],
|
||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
if (!data?.user) throw redirect({ to: '/login' })
|
||||
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw redirect({ to: '/login' })
|
||||
throw e
|
||||
}
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const stats = [
|
||||
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
|
||||
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
|
||||
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
|
||||
]
|
||||
|
||||
function DashboardPage() {
|
||||
const { data } = useSession()
|
||||
const logout = useLogout()
|
||||
const user = data?.user
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<TbLogout size={16} />}
|
||||
onClick={() => logout.mutate()}
|
||||
loading={logout.isPending}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group>
|
||||
<Avatar color="blue" radius="xl" size="lg">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={500}>{user?.name}</Text>
|
||||
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">{user?.email}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
|
||||
<ThemeIcon variant="light" color={stat.color} size="sm">
|
||||
<stat.icon size={14} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fw={700} size="xl">{stat.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
36
src/frontend/routes/index.tsx
Normal file
36
src/frontend/routes/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { SiBun } from 'react-icons/si'
|
||||
import { TbBrandReact, TbLogin, TbRocket } from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<Group gap="lg">
|
||||
<SiBun size={64} color="#fbf0df" />
|
||||
<TbBrandReact size={64} color="#61dafb" />
|
||||
</Group>
|
||||
|
||||
<Title order={1}>Bun + Elysia + Vite + React</Title>
|
||||
|
||||
<Text c="dimmed" ta="center" maw={480}>
|
||||
Full-stack starter template with Mantine UI, TanStack Router, and session-based auth.
|
||||
</Text>
|
||||
|
||||
<Group>
|
||||
<Button component={Link} to="/login" leftSection={<TbLogin size={18} />} variant="filled">
|
||||
Login
|
||||
</Button>
|
||||
<Button component={Link} to="/dashboard" leftSection={<TbRocket size={18} />} variant="light">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
115
src/frontend/routes/login.tsx
Normal file
115
src/frontend/routes/login.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { FcGoogle } from 'react-icons/fc'
|
||||
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
error: (search.error as string) || undefined,
|
||||
}),
|
||||
beforeLoad: async ({ context }) => {
|
||||
try {
|
||||
const data = await context.queryClient.ensureQueryData({
|
||||
queryKey: ['auth', 'session'],
|
||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
if (data?.user) {
|
||||
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) return
|
||||
throw e
|
||||
}
|
||||
},
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
const login = useLogin()
|
||||
const { error: searchError } = Route.useSearch()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
login.mutate({ email, password })
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Paper shadow="md" p="xl" radius="md" w={400} withBorder>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Title order={2} ta="center">
|
||||
Login
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
||||
<br />
|
||||
atau: <strong>user@example.com</strong> / <strong>user123</strong>
|
||||
</Text>
|
||||
|
||||
{(login.isError || searchError) && (
|
||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
||||
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="email@example.com"
|
||||
leftSection={<TbMail size={16} />}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
leftSection={<TbLock size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
leftSection={<TbLogin size={18} />}
|
||||
loading={login.isPending}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Divider label="atau" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="/api/auth/google"
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FcGoogle size={18} />}
|
||||
>
|
||||
Login dengan Google
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
97
src/frontend/routes/profile.tsx
Normal file
97
src/frontend/routes/profile.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { TbLogout, TbUser } from 'react-icons/tb'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/profile')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
try {
|
||||
const data = await context.queryClient.ensureQueryData({
|
||||
queryKey: ['auth', 'session'],
|
||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
if (!data?.user) throw redirect({ to: '/login' })
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw redirect({ to: '/login' })
|
||||
throw e
|
||||
}
|
||||
},
|
||||
component: ProfilePage,
|
||||
})
|
||||
|
||||
const roleBadgeColor: Record<string, string> = {
|
||||
USER: 'blue',
|
||||
ADMIN: 'violet',
|
||||
SUPER_ADMIN: 'red',
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const { data } = useSession()
|
||||
const logout = useLogout()
|
||||
const user = data?.user
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Profile</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<TbLogout size={16} />}
|
||||
onClick={() => logout.mutate()}
|
||||
loading={logout.isPending}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Stack align="center" gap="md">
|
||||
<Avatar color="blue" radius="xl" size={80}>
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text fw={600} size="lg">{user?.name}</Text>
|
||||
<Text c="dimmed" size="sm">{user?.email}</Text>
|
||||
</div>
|
||||
<Badge color={roleBadgeColor[user?.role ?? 'USER']} variant="light" size="lg">
|
||||
{user?.role}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<TbUser size={16} />
|
||||
<Text fw={500} size="sm">Account Info</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Name</Text>
|
||||
<Text size="sm">{user?.name}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Email</Text>
|
||||
<Text size="sm">{user?.email}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Role</Text>
|
||||
<Text size="sm">{user?.role}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user