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:
bipproduction
2026-04-01 10:12:19 +08:00
commit 08a1054e3c
57 changed files with 3732 additions and 0 deletions

40
src/frontend/App.tsx Normal file
View 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>
</>
)
}

View 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',
}}
/>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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' })
},
})
}

View 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} />,
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}