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:
158
src/app.ts
Normal file
158
src/app.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { cors } from '@elysiajs/cors'
|
||||
import { html } from '@elysiajs/html'
|
||||
import { Elysia } from 'elysia'
|
||||
import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.use(cors())
|
||||
.use(html())
|
||||
|
||||
// ─── Global Error Handler ────────────────────────
|
||||
.onError(({ code, error }) => {
|
||||
if (code === 'NOT_FOUND') {
|
||||
return new Response(JSON.stringify({ error: 'Not Found', status: 404 }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
console.error('[Server Error]', error)
|
||||
return new Response(JSON.stringify({ error: 'Internal Server Error', status: 500 }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
.get('/health', () => ({ status: 'ok' }))
|
||||
|
||||
// ─── Auth API ──────────────────────────────────────
|
||||
.post('/api/auth/login', async ({ request, set }) => {
|
||||
const { email, password } = (await request.json()) as { email: string; password: string }
|
||||
let user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user || !(await Bun.password.verify(password, user.password))) {
|
||||
set.status = 401
|
||||
return { error: 'Email atau password salah' }
|
||||
}
|
||||
// Auto-promote super admin from env
|
||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
|
||||
}
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
||||
})
|
||||
|
||||
.post('/api/auth/logout', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
if (token) await prisma.session.deleteMany({ where: { token } })
|
||||
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
.get('/api/auth/session', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
if (!token) { set.status = 401; return { user: null } }
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token },
|
||||
include: { user: { select: { id: true, name: true, email: true, role: true } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) await prisma.session.delete({ where: { id: session.id } })
|
||||
set.status = 401
|
||||
return { user: null }
|
||||
}
|
||||
return { user: session.user }
|
||||
})
|
||||
|
||||
// ─── Google OAuth ──────────────────────────────────
|
||||
.get('/api/auth/google', ({ request, set }) => {
|
||||
const origin = new URL(request.url).origin
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.GOOGLE_CLIENT_ID,
|
||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
})
|
||||
set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}`
|
||||
})
|
||||
|
||||
.get('/api/auth/callback/google', async ({ request, set }) => {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const origin = url.origin
|
||||
|
||||
if (!code) {
|
||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: env.GOOGLE_CLIENT_ID,
|
||||
client_secret: env.GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
||||
return
|
||||
}
|
||||
|
||||
const tokens = (await tokenRes.json()) as { access_token: string }
|
||||
|
||||
// Get user info
|
||||
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
|
||||
if (!userInfoRes.ok) {
|
||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
||||
return
|
||||
}
|
||||
|
||||
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
|
||||
|
||||
// Upsert user (no password for Google users)
|
||||
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: googleUser.email },
|
||||
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
|
||||
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
|
||||
})
|
||||
|
||||
// Create session
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||
|
||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'GET',
|
||||
}))
|
||||
.put('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'PUT',
|
||||
}))
|
||||
.get('/api/hello/:name', ({ params }) => ({
|
||||
message: `Hello, ${params.name}!`,
|
||||
}))
|
||||
}
|
||||
34
src/frontend.tsx
Normal file
34
src/frontend.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './frontend/App'
|
||||
|
||||
// DevInspector hanya di-import saat dev (tree-shaken di production)
|
||||
const InspectorWrapper = import.meta.env?.DEV
|
||||
? (await import('./frontend/DevInspector')).DevInspector
|
||||
: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
|
||||
// Remove splash screen after React mounts
|
||||
function removeSplash() {
|
||||
const splash = document.getElementById('splash')
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out')
|
||||
setTimeout(() => splash.remove(), 300)
|
||||
}
|
||||
}
|
||||
|
||||
const elem = document.getElementById('root')!
|
||||
const app = (
|
||||
<InspectorWrapper>
|
||||
<App />
|
||||
</InspectorWrapper>
|
||||
)
|
||||
|
||||
// HMR-safe: reuse root agar React state preserved saat hot reload
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.root ??= createRoot(elem)
|
||||
import.meta.hot.data.root.render(app)
|
||||
} else {
|
||||
createRoot(elem).render(app)
|
||||
}
|
||||
|
||||
removeSplash()
|
||||
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>
|
||||
)
|
||||
}
|
||||
187
src/index.css
Normal file
187
src/index.css
Normal file
@@ -0,0 +1,187 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.05;
|
||||
background: url("./logo.svg");
|
||||
background-size: 256px;
|
||||
transform: rotate(-12deg) scale(1.35);
|
||||
animation: slide 30s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes slide {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 256px 224px;
|
||||
}
|
||||
}
|
||||
.app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.logo-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.bun-logo {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.bun-logo:hover {
|
||||
filter: drop-shadow(0 0 2em #fbf0dfaa);
|
||||
}
|
||||
.react-logo {
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
.react-logo:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
code {
|
||||
background-color: #1a1a1a;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.3em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.api-tester {
|
||||
margin: 2rem auto 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
font: monospace;
|
||||
border: 2px solid #fbf0df;
|
||||
transition: 0.3s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.endpoint-row:focus-within {
|
||||
border-color: #f3d5a3;
|
||||
}
|
||||
.method {
|
||||
background: #fbf0df;
|
||||
color: #1a1a1a;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
width: min-content;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
}
|
||||
.method option {
|
||||
text-align: left;
|
||||
}
|
||||
.url-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: 0;
|
||||
border: 0;
|
||||
color: #fbf0df;
|
||||
font: 1em monospace;
|
||||
padding: 0.2rem;
|
||||
outline: 0;
|
||||
}
|
||||
.url-input:focus {
|
||||
color: #fff;
|
||||
}
|
||||
.url-input::placeholder {
|
||||
color: rgba(251, 240, 223, 0.4);
|
||||
}
|
||||
.send-button {
|
||||
background: #fbf0df;
|
||||
color: #1a1a1a;
|
||||
border: 0;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
transition: 0.1s;
|
||||
cursor: var(--bun-cursor);
|
||||
}
|
||||
.send-button:hover {
|
||||
background: #f3d5a3;
|
||||
transform: translateY(-1px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.response-area {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #fbf0df;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
color: #fbf0df;
|
||||
font: monospace;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.response-area:focus {
|
||||
border-color: #f3d5a3;
|
||||
}
|
||||
.response-area::placeholder {
|
||||
color: rgba(251, 240, 223, 0.4);
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
177
src/index.tsx
Normal file
177
src/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { env } from './lib/env'
|
||||
|
||||
const isProduction = env.NODE_ENV === 'production'
|
||||
|
||||
// ─── Route Classification ──────────────────────────────
|
||||
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health']
|
||||
|
||||
function isApiRoute(pathname: string): boolean {
|
||||
return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health'
|
||||
}
|
||||
|
||||
// ─── Vite Dev Server (dev only) ────────────────────────
|
||||
let vite: Awaited<ReturnType<typeof import('./vite').createVite>> | null = null
|
||||
if (!isProduction) {
|
||||
const { createVite } = await import('./vite')
|
||||
vite = await createVite()
|
||||
}
|
||||
|
||||
// ─── Frontend Serving ──────────────────────────────────
|
||||
async function serveFrontend(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
if (!isProduction && vite) {
|
||||
// === DEVELOPMENT: Vite Middleware Mode ===
|
||||
|
||||
// SPA route → serve index.html via Vite transform
|
||||
if (
|
||||
pathname === '/' ||
|
||||
(!pathname.includes('.') &&
|
||||
!pathname.startsWith('/@') &&
|
||||
!pathname.startsWith('/__open-stack-frame-in-editor'))
|
||||
) {
|
||||
const htmlPath = path.resolve('index.html')
|
||||
let htmlContent = fs.readFileSync(htmlPath, 'utf-8')
|
||||
htmlContent = await vite.transformIndexHtml(pathname, htmlContent)
|
||||
|
||||
// Dedupe: Vite 8 middlewareMode injects react-refresh preamble twice
|
||||
const preamble =
|
||||
'<script type="module">import { injectIntoGlobalHook } from "/@react-refresh";\ninjectIntoGlobalHook(window);\nwindow.$RefreshReg$ = () => {};\nwindow.$RefreshSig$ = () => (type) => type;</script>'
|
||||
const firstIdx = htmlContent.indexOf(preamble)
|
||||
if (firstIdx !== -1) {
|
||||
const secondIdx = htmlContent.indexOf(preamble, firstIdx + preamble.length)
|
||||
if (secondIdx !== -1) {
|
||||
htmlContent = htmlContent.slice(0, secondIdx) + htmlContent.slice(secondIdx + preamble.length)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(htmlContent, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
}
|
||||
|
||||
// Asset/module requests → proxy ke Vite middleware
|
||||
// Bridge: Bun Request → Node.js IncomingMessage/ServerResponse
|
||||
return new Promise<Response>((resolve) => {
|
||||
const req = new Proxy(request, {
|
||||
get(target, prop) {
|
||||
if (prop === 'url') return pathname + url.search
|
||||
if (prop === 'method') return request.method
|
||||
if (prop === 'headers') return Object.fromEntries(request.headers as any)
|
||||
return (target as any)[prop]
|
||||
},
|
||||
}) as any
|
||||
|
||||
const chunks: (Buffer | Uint8Array)[] = []
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
setHeader(name: string, value: string) { this.headers[name.toLowerCase()] = value; return this },
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()] },
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()] },
|
||||
writeHead(code: number, reasonOrHeaders?: string | Record<string, string>, maybeHeaders?: Record<string, string>) {
|
||||
this.statusCode = code
|
||||
const hdrs = typeof reasonOrHeaders === 'object' ? reasonOrHeaders : maybeHeaders
|
||||
if (hdrs) for (const [k, v] of Object.entries(hdrs)) this.headers[k.toLowerCase()] = String(v)
|
||||
return this
|
||||
},
|
||||
write(chunk: any) {
|
||||
if (chunk) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
||||
return true
|
||||
},
|
||||
end(data?: any) {
|
||||
if (data) {
|
||||
if (typeof data === 'string') chunks.push(Buffer.from(data))
|
||||
else if (data instanceof Uint8Array || Buffer.isBuffer(data)) chunks.push(data)
|
||||
}
|
||||
resolve(new Response(
|
||||
chunks.length > 0 ? Buffer.concat(chunks) : null,
|
||||
{ status: this.statusCode, headers: this.headers },
|
||||
))
|
||||
},
|
||||
once() { return this },
|
||||
on() { return this },
|
||||
emit() { return this },
|
||||
removeListener() { return this },
|
||||
} as any
|
||||
|
||||
vite.middlewares(req, res, (err: any) => {
|
||||
if (err) {
|
||||
resolve(new Response(err.stack || err.toString(), { status: 500 }))
|
||||
return
|
||||
}
|
||||
resolve(new Response('Not Found', { status: 404 }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// === PRODUCTION: Static Files + SPA Fallback ===
|
||||
const filePath = path.join('dist', pathname === '/' ? 'index.html' : pathname)
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath)
|
||||
const contentType: Record<string, string> = {
|
||||
'.js': 'application/javascript', '.css': 'text/css',
|
||||
'.html': 'text/html; charset=utf-8', '.json': 'application/json',
|
||||
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
|
||||
}
|
||||
const isHashed = pathname.startsWith('/assets/')
|
||||
return new Response(Bun.file(filePath), {
|
||||
headers: {
|
||||
'Content-Type': contentType[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': isHashed ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SPA fallback — semua route yang tidak match file → index.html
|
||||
const indexHtml = path.join('dist', 'index.html')
|
||||
if (fs.existsSync(indexHtml)) {
|
||||
return new Response(Bun.file(indexHtml), {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
// ─── Elysia App ────────────────────────────────────────
|
||||
import { createApp } from './app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
// Frontend intercept — onRequest jalan SEBELUM route matching
|
||||
.onRequest(async ({ request }) => {
|
||||
const pathname = new URL(request.url).pathname
|
||||
|
||||
// Dev inspector: open file di editor
|
||||
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
|
||||
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
|
||||
relativePath: string; lineNumber: string; columnNumber: string
|
||||
}
|
||||
const file = `${process.cwd()}/${relativePath}`
|
||||
const editor = env.REACT_EDITOR
|
||||
const loc = `${file}:${lineNumber}:${columnNumber}`
|
||||
// zed & subl: editor file:line:col — code & cursor: editor --goto file:line:col
|
||||
const noGotoEditors = ['subl', 'zed']
|
||||
const args = noGotoEditors.includes(editor) ? [loc] : ['--goto', loc]
|
||||
const editorPath = Bun.which(editor)
|
||||
if (editorPath) Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
return new Response('ok')
|
||||
}
|
||||
|
||||
// Non-API route → serve frontend
|
||||
if (!isApiRoute(pathname)) {
|
||||
return serveFrontend(request)
|
||||
}
|
||||
// undefined → lanjut ke Elysia route matching
|
||||
})
|
||||
|
||||
.listen(env.PORT)
|
||||
|
||||
console.log(`Server running at http://localhost:${app.server!.port}`)
|
||||
13
src/lib/db.ts
Normal file
13
src/lib/db.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '../../generated/prisma'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
19
src/lib/env.ts
Normal file
19
src/lib/env.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
function optional(key: string, fallback: string): string {
|
||||
return process.env[key] ?? fallback
|
||||
}
|
||||
|
||||
function required(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value) throw new Error(`Missing required environment variable: ${key}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const env = {
|
||||
PORT: parseInt(optional('PORT', '3000'), 10),
|
||||
NODE_ENV: optional('NODE_ENV', 'development'),
|
||||
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
|
||||
DATABASE_URL: required('DATABASE_URL'),
|
||||
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
||||
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
||||
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
|
||||
} as const
|
||||
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
8
src/react.svg
Normal file
8
src/react.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||
<ellipse rx="11" ry="4.2"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
3
src/serve.ts
Normal file
3
src/serve.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Workaround: Bun 1.3.6 EADDRINUSE errno:0
|
||||
// Dynamic import memberi waktu OS release port sebelum binding
|
||||
import('./index.tsx')
|
||||
180
src/vite.ts
Normal file
180
src/vite.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import type { Plugin } from 'vite'
|
||||
import { createServer as createViteServer } from 'vite'
|
||||
|
||||
/**
|
||||
* Custom Vite plugin: inject data-inspector-* attributes ke JSX via regex.
|
||||
* enforce: "pre" → jalan SEBELUM OXC transform JSX.
|
||||
*
|
||||
* Karena plugin lain (OXC, TanStack) bisa mengubah source sebelum kita
|
||||
* (collapse lines, resolve imports), kita baca file ASLI dari disk untuk
|
||||
* line number yang akurat, lalu cross-reference dengan code yang diterima.
|
||||
*/
|
||||
function inspectorPlugin(): Plugin {
|
||||
const rootDir = process.cwd()
|
||||
return {
|
||||
name: 'inspector-inject',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||
if (!code.includes('<')) return null
|
||||
|
||||
const cleanId = id.replace(/\?.*$/, '')
|
||||
const relativePath = path.relative(rootDir, cleanId)
|
||||
|
||||
// Baca file asli dari disk untuk line number akurat
|
||||
let originalLines: string[] | null = null
|
||||
try {
|
||||
originalLines = fs.readFileSync(cleanId, 'utf-8').split('\n')
|
||||
} catch {}
|
||||
|
||||
let modified = false
|
||||
let lastOrigIdx = 0
|
||||
|
||||
const lines = code.split('\n')
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*|[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*))\b/g
|
||||
let match: RegExpExecArray | null = null
|
||||
|
||||
while ((match = jsxPattern.exec(line)) !== null) {
|
||||
const charBefore = match.index > 0 ? line[match.index - 1] : ''
|
||||
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
|
||||
|
||||
// Cari line number asli di file original
|
||||
let actualLine = i + 1
|
||||
if (originalLines) {
|
||||
const afterTag = line.slice(match.index)
|
||||
// Snippet: tag + atribut sampai '>' pertama, tanpa injected attrs
|
||||
const snippet = afterTag.split('>')[0]
|
||||
.replace(/\s*data-inspector-[^"]*"[^"]*"/g, '')
|
||||
.trim()
|
||||
// Tag name saja sebagai fallback (e.g. "<Button")
|
||||
const tagName = match[1]
|
||||
|
||||
let found = false
|
||||
|
||||
// 1) Forward search dengan full snippet
|
||||
for (let j = lastOrigIdx; j < originalLines.length; j++) {
|
||||
if (originalLines[j].includes(snippet)) {
|
||||
actualLine = j + 1
|
||||
lastOrigIdx = j + 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: forward search hanya tag name (handle multi-line collapsed)
|
||||
// Penting untuk <Button\n attr="..."\n> yang di-collapse jadi 1 baris
|
||||
if (!found) {
|
||||
for (let j = lastOrigIdx; j < originalLines.length; j++) {
|
||||
if (originalLines[j].includes(tagName)) {
|
||||
actualLine = j + 1
|
||||
lastOrigIdx = j + 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Last resort: search dari awal dengan full snippet
|
||||
if (!found) {
|
||||
for (let j = 0; j < originalLines.length; j++) {
|
||||
if (originalLines[j].includes(snippet)) {
|
||||
actualLine = j + 1
|
||||
lastOrigIdx = j + 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Last resort: search dari awal dengan tag name
|
||||
if (!found) {
|
||||
for (let j = 0; j < originalLines.length; j++) {
|
||||
if (originalLines[j].includes(tagName) && !originalLines[j].trim().startsWith('</')) {
|
||||
actualLine = j + 1
|
||||
lastOrigIdx = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const col = match.index + 1
|
||||
const attr = ` data-inspector-line="${actualLine}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
|
||||
const insertPos = match.index + match[0].length
|
||||
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
|
||||
modified = true
|
||||
jsxPattern.lastIndex += attr.length
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
if (!modified) return null
|
||||
return result.join('\n')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround: @vitejs/plugin-react v6 + Vite 8 middlewareMode
|
||||
* inject React Refresh HMR footer 2x → "Identifier RefreshRuntime already declared".
|
||||
* Plugin ini hapus duplikat setelah semua transform selesai.
|
||||
*/
|
||||
function dedupeRefreshPlugin(): Plugin {
|
||||
return {
|
||||
name: 'dedupe-react-refresh',
|
||||
enforce: 'post',
|
||||
transform(code, id) {
|
||||
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||
|
||||
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
|
||||
const firstIdx = code.indexOf(marker)
|
||||
if (firstIdx === -1) return null
|
||||
|
||||
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
|
||||
if (secondIdx === -1) return null
|
||||
|
||||
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
|
||||
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
|
||||
return { code: code.slice(0, secondIdx) + code.slice(endIdx), map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function createVite() {
|
||||
return createViteServer({
|
||||
root: process.cwd(),
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
routesDirectory: './src/frontend/routes',
|
||||
generatedRouteTree: './src/frontend/routeTree.gen.ts',
|
||||
routeFileIgnorePrefix: '-',
|
||||
quoteStyle: 'single',
|
||||
}),
|
||||
inspectorPlugin(),
|
||||
react(),
|
||||
dedupeRefreshPlugin(),
|
||||
],
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: { port: 24678 },
|
||||
allowedHosts: true,
|
||||
},
|
||||
appType: 'custom',
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom'],
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user