Progress Tampilan UI Dashboard Desa Plus NOC

This commit is contained in:
2026-03-17 20:53:33 +07:00
parent 8c35d58b38
commit f0c37272b9
21 changed files with 574 additions and 435 deletions

302
PromptDashboard.md Normal file
View File

@@ -0,0 +1,302 @@
Buat halaman dashboard admin modern untuk sistem pemerintahan desa bernama **Darmasaba Dashboard NOC**.
Gunakan stack berikut:
Frontend:
* React 19
* Bun runtime
* Vite
* TailwindCSS
* Mantine UI
* Mantine Charts atau Recharts
* Tabler Icons
* TanStack Router
* Dayjs
UI harus modular dengan reusable components.
Gunakan **TailwindCSS sebagai styling utama** dengan warna dari konfigurasi berikut:
Primary:
darmasaba-navy (#1E3A5F)
Secondary:
darmasaba-blue (#3B82F6)
Success:
#22C55E
Warning:
#FACC15
Danger:
#EF4444
Background:
#F5F8FB
Dashboard harus memiliki **Light Mode dan Dark Mode**.
Dark Mode Color Rules:
background: #0F172A
card: #1E293B
border: #334155
text: #E2E8F0
Card style:
* rounded-xl
* soft shadow
* padding besar
* border subtle
* smooth hover animation
Gunakan grid layout responsive.
---
SECTION 1 — PROGRAM KEGIATAN
Buat 4 card horizontal di bagian atas yang menampilkan kegiatan desa.
Setiap card memiliki:
* header biru
* progress bar kegiatan
* tanggal kegiatan
* badge status
Data card:
1.
Judul: Rakor 2025
Tanggal: 3 Juli 2025
Progress: 90%
Status: selesai
2.
Judul: Pemutakhiran Indeks Desa
Tanggal: 3 Juli 2025
Progress: 85%
Status: selesai
3.
Judul: Mengurus Akta Cerai Warga
Tanggal: 3 Juli 2025
Progress: 80%
Status: selesai
4.
Judul: Pasek 7 Desa Adat
Tanggal: 3 Juli 2025
Progress: 92%
Status: selesai
Progress bar:
* rounded
* warna warning
* animasi smooth
Status badge:
* success color
---
SECTION 2 — GRID DASHBOARD
Layout:
3 column grid.
Left column (sidebar style):
Divisi Teraktif
List item card dengan arrow icon.
Data:
Kesejahteraan — 37 kegiatan
Pemerintahan — 26 kegiatan
Keuangan — 17 kegiatan
Sekretaris Desa — 15 kegiatan
Tata Usaha TK — 14 kegiatan
Perangkat Kewilayahan — 12 kegiatan
Pelayanan — 10 kegiatan
Perencanaan — 9 kegiatan
Tata Usaha & Umum — 7 kegiatan
Setiap item:
* rounded
* hover effect
* arrow icon kanan
---
Middle column:
Jumlah Dokumen
Gunakan **Bar Chart**.
Kategori:
* Gambar
* Dokumen
Nilai:
* Gambar: 300
* Dokumen: 310
Gunakan:
Recharts atau Mantine Charts.
---
Right column:
Progres Kegiatan
Gunakan **Pie Chart**.
Data:
Selesai — 83.33%
Dikerjakan — 16.67%
Segera Dikerjakan — 0%
Dibatalkan — 0%
Legend harus berwarna.
---
SECTION 3 — DISCUSSION PANEL
Judul: Diskusi
Tampilkan list diskusi internal staf.
Item card memiliki:
* icon chat
* judul pesan
* nama pengirim
* tanggal
Contoh data:
"Kepada Pelayanan, mohon di cek..."
Pengirim: I.B Surya Prabhawa Manu
Tanggal: 12 Apr 2025
"Kepada staf perencanaan @suar..."
Pengirim: Ni Nyoman Yuliani
Tanggal: 14 Jun 2025
"ijin atau mohon kepada KBD sar..."
Pengirim: Ni Wayan Martini
Tanggal: 12 Apr 2025
---
SECTION 4 — ACARA HARI INI
Card sederhana.
Jika tidak ada acara tampilkan:
"Tidak ada acara hari ini"
---
SECTION 5 — ARSIP DIGITAL PERANGKAT DESA
Grid 2 column.
Menu arsip:
Surat Keputusan
Dokumentasi
Laporan Keuangan
Notulensi Rapat
Setiap item berupa card clickable dengan:
* icon dokumen
* border
* hover effect
---
DESIGN STYLE
Gunakan gaya:
Modern Government Dashboard
Clean UI
Soft shadow
Rounded-xl
Spacing besar
Minimalistic
---
RESPONSIVE RULES
Desktop:
12 column grid
Tablet:
6 column grid
Mobile:
single column stack
---
COMPONENT STRUCTURE
src/components/dashboard
activity-card.tsx
division-list.tsx
document-chart.tsx
progress-chart.tsx
discussion-panel.tsx
event-card.tsx
archive-card.tsx
src/pages
dashboard.tsx
---
CODE QUALITY
Gunakan:
* React hooks
* reusable components
* Mantine components jika perlu
* Tailwind utility classes
* dark mode support
* responsive layout
* clean TypeScript
---
Output:
* Halaman dashboard lengkap
* Semua komponen reusable
* Chart sudah bekerja
* Layout identik dengan desain dashboard modern pemerintahan

View File

@@ -1,4 +1,4 @@
import { Grid, Stack, useMantineColorScheme } from "@mantine/core"; import { Grid, Image, Stack, useMantineColorScheme } from "@mantine/core";
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react"; import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
import { ActivityList } from "./dashboard/activity-list"; import { ActivityList } from "./dashboard/activity-list";
import { ChartAPBDes } from "./dashboard/chart-apbdes"; import { ChartAPBDes } from "./dashboard/chart-apbdes";
@@ -8,149 +8,26 @@ import { SatisfactionChart } from "./dashboard/satisfaction-chart";
import { SDGSCard } from "./dashboard/sdgs-card"; import { SDGSCard } from "./dashboard/sdgs-card";
import { StatCard } from "./dashboard/stat-card"; import { StatCard } from "./dashboard/stat-card";
// SDGs Icons
function EnergyIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24 4L14 24H22L20 44L34 20H26L24 4Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PeaceIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="24" cy="24" r="20" stroke="currentColor" strokeWidth="2" />
<path
d="M24 4V44M24 24L10 38M24 24L38 38"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function HealthIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24 44C24 44 6 28 6 18C6 11.373 11.373 6 18 6C21.5 6 24.5 7.5 24 12C23.5 7.5 26.5 6 30 6C36.627 6 42 11.373 42 18C42 28 24 44 24 44Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
);
}
function PovertyIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="6" y="18" width="36" height="26" rx="2" fill="currentColor" />
<path
d="M14 18V12C14 8.686 16.686 6 20 6H28C31.314 6 34 8.686 34 12V18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function OceanIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 30C6 30 10 26 14 30C18 34 22 30 26 30C30 30 34 34 38 30C42 26 46 30 46 30"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M6 38C6 38 10 34 14 38C18 42 22 38 26 38C30 38 34 42 38 38C42 34 46 38 46 38"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="24" cy="16" r="6" fill="currentColor" />
</svg>
);
}
const sdgsData = [ const sdgsData = [
{ {
title: "Desa Berenergi Bersih dan Terbarukan", title: "Desa Berenergi Bersih dan Terbarukan",
score: 99.64, score: 99.64,
icon: <EnergyIcon />, image: "SDGS-7.png",
color: "#FACC15",
bgColor: "#FEF9C3",
}, },
{ {
title: "Desa Damai Berkeadilan", title: "Desa Damai Berkeadilan",
score: 78.65, score: 78.65,
icon: <PeaceIcon />, image: "SDGS-16.png",
color: "#3B82F6",
bgColor: "#DBEAFE",
}, },
{ {
title: "Desa Sehat dan Sejahtera", title: "Desa Sehat dan Sejahtera",
score: 77.37, score: 77.37,
icon: <HealthIcon />, image: "SDGS-3.png",
color: "#22C55E",
bgColor: "#DCFCE7",
}, },
{ {
title: "Desa Tanpa Kemiskinan", title: "Desa Tanpa Kemiskinan",
score: 52.62, score: 52.62,
icon: <PovertyIcon />, image: "SDGS-1.png",
color: "#EF4444",
bgColor: "#FEE2E2",
},
{
title: "Desa Peduli Lingkungan Laut",
score: 50.0,
icon: <OceanIcon />,
color: "#06B6D4",
bgColor: "#CFFAFE",
}, },
]; ];
@@ -202,37 +79,35 @@ export function DashboardContent() {
{/* Section 2: Chart & Division Progress */} {/* Section 2: Chart & Division Progress */}
<Grid gutter="lg"> <Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 7 }}>
<ChartSurat /> <ChartSurat />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 5 }}>
<DivisionProgress />
</Grid.Col>
</Grid>
{/* Section 3: APBDes Chart */}
<ChartAPBDes />
{/* Section 4 & 5: Activity List & Satisfaction Chart */}
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 6 }}>
<ActivityList />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 6 }}>
<SatisfactionChart /> <SatisfactionChart />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
{/* Section 3: APBDes Chart */}
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 7 }}>
<DivisionProgress />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<ActivityList />
{/* <SatisfactionChart /> */}
</Grid.Col>
</Grid>
<ChartAPBDes />
{/* Section 6: SDGs Desa Cards */} {/* Section 6: SDGs Desa Cards */}
<Grid gutter="md"> <Grid gutter="md">
{sdgsData.map((sdg, index) => ( {sdgsData.map((sdg, index) => (
<Grid.Col key={index} span={{ base: 12, sm: 6, md: 4, lg: 2.4 }}> <Grid.Col key={index} span={{ base: 9, md: 3 }}>
<SDGSCard <SDGSCard
image={<Image src={sdg.image} alt={sdg.title} />}
title={sdg.title} title={sdg.title}
score={sdg.score} score={sdg.score}
icon={sdg.icon}
color={sdg.color}
bgColor={sdg.bgColor}
/> />
</Grid.Col> </Grid.Col>
))} ))}

View File

@@ -4,18 +4,10 @@ import type { ReactNode } from "react";
interface SDGSCardProps { interface SDGSCardProps {
title: string; title: string;
score: number; score: number;
icon: ReactNode; image: ReactNode;
color: string;
bgColor: string;
} }
export function SDGSCard({ export function SDGSCard({ title, score, image }: SDGSCardProps) {
title,
score,
icon,
color,
bgColor,
}: SDGSCardProps) {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark"; const dark = colorScheme === "dark";
@@ -24,29 +16,28 @@ export function SDGSCard({
p="md" p="md"
radius="xl" radius="xl"
withBorder withBorder
bg={bgColor}
style={{ style={{
borderColor: dark ? "#334155" : bgColor, borderColor: dark ? "#334155" : "white",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)", boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1)",
}} }}
h="100%"
> >
<Group justify="space-between" align="flex-start" w="100%"> <Group justify="space-between" align="flex-start" w="100%">
<Box>{image}</Box>
<Box style={{ flex: 1 }}> <Box style={{ flex: 1 }}>
<Text size="sm" c={dark ? "white" : "gray.8"} fw={500} mb="xs"> <Text
ta={"center"}
size="sm"
c={dark ? "white" : "gray.8"}
fw={500}
mb="xs"
>
{title} {title}
</Text> </Text>
<Text size="xl" fw={700} c={color}> <Text ta={"center"} size="xl" c={dark ? "white" : "gray.8"} fw={700}>
{score.toFixed(2)} {score.toFixed(2)}
</Text> </Text>
</Box> </Box>
<Box
style={{
color,
opacity: 0.8,
}}
>
{icon}
</Box>
</Group> </Group>
</Card> </Card>
); );

View File

@@ -1,13 +1,12 @@
import { Grid, Stack } from "@mantine/core"; import { Grid, Stack } from "@mantine/core";
import { import { ActivityCard, } from "./kinerja-divisi/activity-card";
ActivityCard, import { DivisionList } from "./kinerja-divisi/division-list";
ArchiveCard, import { DocumentChart } from "./kinerja-divisi/document-chart";
DiscussionPanel, import { ProgressChart } from "./kinerja-divisi/progress-chart";
DivisionList, import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
DocumentChart, import { EventCard } from "./kinerja-divisi/event-card";
EventCard, import { ArchiveCard } from "./kinerja-divisi/archive-card";
ProgressChart,
} from ".";
// Data for program kegiatan (Section 1) // Data for program kegiatan (Section 1)
const programKegiatanData = [ const programKegiatanData = [

View File

@@ -3,6 +3,7 @@ import {
Box, Box,
Collapse, Collapse,
Group, Group,
Image,
Input, Input,
NavLink as MantineNavLink, NavLink as MantineNavLink,
Stack, Stack,
@@ -60,30 +61,7 @@ export function Sidebar({ className }: SidebarProps) {
return ( return (
<Box className={className}> <Box className={className}>
{/* Logo */} {/* Logo */}
<Box <Image src="/logo-desa-plus.png" alt="Logo" />
p="md"
style={{ borderBottom: "1px solid var(--mantine-color-gray-3)" }}
>
<Group gap="xs">
<Badge
color="dark"
variant="filled"
size="xl"
radius="md"
py="xs"
px="md"
style={{ fontSize: "1.5rem", fontWeight: "bold" }}
>
DESA
</Badge>
<Badge color="green" variant="filled" size="md" radius="md">
+
</Badge>
</Group>
<Text size="xs" c="dimmed" mt="xs">
Digitalisasi Desa Transparansi Kerja
</Text>
</Box>
{/* Search */} {/* Search */}
<Box p="md"> <Box p="md">

View File

@@ -12,240 +12,240 @@ const isProduction = process.env.NODE_ENV === "production";
// Auto-seed database in production (ensure admin user exists) // Auto-seed database in production (ensure admin user exists)
if (isProduction && process.env.ADMIN_EMAIL) { if (isProduction && process.env.ADMIN_EMAIL) {
try { try {
console.log("🌱 Running database seed in production..."); console.log("🌱 Running database seed in production...");
const { runSeed } = await import("../prisma/seed.ts"); const { runSeed } = await import("../prisma/seed.ts");
await runSeed(); await runSeed();
} catch (error) { } catch (error) {
console.error("⚠️ Production seed failed:", error); console.error("⚠️ Production seed failed:", error);
// Don't crash the server if seed fails // Don't crash the server if seed fails
} }
} }
const app = new Elysia().use(api); const app = new Elysia().use(api);
if (!isProduction) { if (!isProduction) {
// Development: Use Vite middleware // Development: Use Vite middleware
const { createVite } = await import("./vite"); const { createVite } = await import("./vite");
const vite = await createVite(); const vite = await createVite();
// Serve PWA/TWA assets in dev (root and nested path support) // Serve PWA/TWA assets in dev (root and nested path support)
const _servePwaAsset = (srcPath: string) => () => Bun.file(srcPath); const _servePwaAsset = (srcPath: string) => () => Bun.file(srcPath);
app.post("/__open-in-editor", ({ body }) => { app.post("/__open-in-editor", ({ body }) => {
const { relativePath, lineNumber, columnNumber } = body as { const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string; relativePath: string;
lineNumber: number; lineNumber: number;
columnNumber: number; columnNumber: number;
}; };
openInEditor(relativePath, { openInEditor(relativePath, {
line: lineNumber, line: lineNumber,
column: columnNumber, column: columnNumber,
editor: "antigravity", editor: "antigravity",
}); });
return { ok: true }; return { ok: true };
}); });
// Vite middleware for other requests // Vite middleware for other requests
app.all("*", async ({ request }) => { app.all("*", async ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
// Serve transformed index.html for root or any path that should be handled by the SPA // Serve transformed index.html for root or any path that should be handled by the SPA
if ( if (
pathname === "/" || pathname === "/" ||
(!pathname.includes(".") && (!pathname.includes(".") &&
!pathname.startsWith("/@") && !pathname.startsWith("/@") &&
!pathname.startsWith("/inspector") && !pathname.startsWith("/inspector") &&
!pathname.startsWith("/__open-stack-frame-in-editor") && !pathname.startsWith("/__open-stack-frame-in-editor") &&
!pathname.startsWith("/api")) !pathname.startsWith("/api"))
) { ) {
try { try {
const htmlPath = path.resolve("src/index.html"); const htmlPath = path.resolve("src/index.html");
let html = fs.readFileSync(htmlPath, "utf-8"); let html = fs.readFileSync(htmlPath, "utf-8");
html = await vite.transformIndexHtml(pathname, html); html = await vite.transformIndexHtml(pathname, html);
return new Response(html, { return new Response(html, {
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html" },
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
// Use a Proxy to mock Node.js req because Bun's Request is read-only // Use a Proxy to mock Node.js req because Bun's Request is read-only
const req = new Proxy(request, { const req = new Proxy(request, {
get(target, prop) { get(target, prop) {
if (prop === "url") return pathname + url.search; if (prop === "url") return pathname + url.search;
if (prop === "method") return request.method; if (prop === "method") return request.method;
if (prop === "headers") if (prop === "headers")
return Object.fromEntries(request.headers as any); return Object.fromEntries(request.headers as any);
return (target as any)[prop]; return (target as any)[prop];
}, },
}) as any; }) as any;
const res = { const res = {
statusCode: 200, statusCode: 200,
setHeader(name: string, value: string) { setHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value; this.headers[name.toLowerCase()] = value;
}, },
getHeader(name: string) { getHeader(name: string) {
return this.headers[name.toLowerCase()]; return this.headers[name.toLowerCase()];
}, },
writeHead(code: number, headers: Record<string, string>) { writeHead(code: number, headers: Record<string, string>) {
this.statusCode = code; this.statusCode = code;
Object.assign(this.headers, headers); Object.assign(this.headers, headers);
}, },
write(chunk: any, callback?: () => void) { write(chunk: any, callback?: () => void) {
// Collect chunks for streaming responses // Collect chunks for streaming responses
if (!this._chunks) this._chunks = []; if (!this._chunks) this._chunks = [];
this._chunks.push(chunk); this._chunks.push(chunk);
if (callback) callback(); if (callback) callback();
return true; // Indicate we can accept more data return true; // Indicate we can accept more data
}, },
headers: {} as Record<string, string>, headers: {} as Record<string, string>,
end(data: any) { end(data: any) {
// Handle potential Buffer or string data from Vite // Handle potential Buffer or string data from Vite
let body = data; let body = data;
// If we have collected chunks from write() calls, combine them // If we have collected chunks from write() calls, combine them
if (this._chunks && this._chunks.length > 0) { if (this._chunks && this._chunks.length > 0) {
body = Buffer.concat(this._chunks); body = Buffer.concat(this._chunks);
} }
if (data instanceof Uint8Array) { if (data instanceof Uint8Array) {
body = data; body = data;
} else if (typeof data === "string") { } else if (typeof data === "string") {
body = data; body = data;
} else if (data) { } else if (data) {
body = String(data); body = String(data);
} }
resolve( resolve(
new Response(body || "", { new Response(body || "", {
status: this.statusCode, status: this.statusCode,
headers: this.headers, headers: this.headers,
}), }),
); );
}, },
// Minimal event emitter mock // Minimal event emitter mock
once() { once() {
return this; return this;
}, },
on() { on() {
return this; return this;
}, },
emit() { emit() {
return this; return this;
}, },
removeListener() { removeListener() {
return this; return this;
}, },
} as any; } as any;
vite.middlewares(req, res, (err: any) => { vite.middlewares(req, res, (err: any) => {
if (err) { if (err) {
console.error("Vite middleware error:", err); console.error("Vite middleware error:", err);
resolve(new Response(err.stack || err.toString(), { status: 500 })); resolve(new Response(err.stack || err.toString(), { status: 500 }));
return; return;
} }
// If Vite doesn't handle it, return 404 // If Vite doesn't handle it, return 404
resolve(new Response("Not Found", { status: 404 })); resolve(new Response("Not Found", { status: 404 }));
}); });
}); });
}); });
} else { } else {
// Production: Final catch-all for static files and SPA fallback // Production: Final catch-all for static files and SPA fallback
app.all("*", async ({ request }) => { app.all("*", async ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
// 1. Try exact match in dist // 1. Try exact match in dist
let filePath = path.join( let filePath = path.join(
"dist", "dist",
pathname === "/" ? "index.html" : pathname, pathname === "/" ? "index.html" : pathname,
); );
// 1.1 Special handling for PWA/TWA assets that might not be in dist (since we use custom bun build) // 1.1 Special handling for PWA/TWA assets that might not be in dist (since we use custom bun build)
if (isProduction) { if (isProduction) {
const srcPath = path.join("src", pathname); const srcPath = path.join("src", pathname);
if (fs.existsSync(srcPath)) { if (fs.existsSync(srcPath)) {
filePath = srcPath; filePath = srcPath;
} }
// Check public folder for static assets // Check public folder for static assets
const publicPath = path.join("public", pathname); const publicPath = path.join("public", pathname);
if (fs.existsSync(publicPath)) { if (fs.existsSync(publicPath)) {
filePath = publicPath; filePath = publicPath;
} }
} }
// 2. If not found and looks like an asset (has extension), try root of dist or src // 2. If not found and looks like an asset (has extension), try root of dist or src
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
if (pathname.includes(".") && !pathname.endsWith("/")) { if (pathname.includes(".") && !pathname.endsWith("/")) {
const filename = path.basename(pathname); const filename = path.basename(pathname);
// Try root of dist // Try root of dist
const fallbackDistPath = path.join("dist", filename); const fallbackDistPath = path.join("dist", filename);
if ( if (
fs.existsSync(fallbackDistPath) && fs.existsSync(fallbackDistPath) &&
fs.statSync(fallbackDistPath).isFile() fs.statSync(fallbackDistPath).isFile()
) { ) {
filePath = fallbackDistPath; filePath = fallbackDistPath;
} }
// Try public folder // Try public folder
else { else {
const fallbackPublicPath = path.join("public", filename); const fallbackPublicPath = path.join("public", filename);
if ( if (
fs.existsSync(fallbackPublicPath) && fs.existsSync(fallbackPublicPath) &&
fs.statSync(fallbackPublicPath).isFile() fs.statSync(fallbackPublicPath).isFile()
) { ) {
filePath = fallbackPublicPath; filePath = fallbackPublicPath;
} }
} }
// Special handling for PWA files in src // Special handling for PWA files in src
if (pathname.includes("assetlinks.json")) { if (pathname.includes("assetlinks.json")) {
const srcFilename = pathname.includes("assetlinks.json") const srcFilename = pathname.includes("assetlinks.json")
? ".well-known/assetlinks.json" ? ".well-known/assetlinks.json"
: filename; : filename;
const fallbackSrcPath = path.join("src", srcFilename); const fallbackSrcPath = path.join("src", srcFilename);
if ( if (
fs.existsSync(fallbackSrcPath) && fs.existsSync(fallbackSrcPath) &&
fs.statSync(fallbackSrcPath).isFile() fs.statSync(fallbackSrcPath).isFile()
) { ) {
filePath = fallbackSrcPath; filePath = fallbackSrcPath;
} }
} }
} }
} }
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const file = Bun.file(filePath); const file = Bun.file(filePath);
return new Response(file, { return new Response(file, {
headers: { headers: {
Vary: "Accept-Encoding", Vary: "Accept-Encoding",
}, },
}); });
} }
// 3. SPA Fallback: Serve index.html // 3. SPA Fallback: Serve index.html
const indexHtml = path.join("dist", "index.html"); const indexHtml = path.join("dist", "index.html");
if (fs.existsSync(indexHtml)) { if (fs.existsSync(indexHtml)) {
return new Response(Bun.file(indexHtml), { return new Response(Bun.file(indexHtml), {
headers: { headers: {
Vary: "Accept-Encoding", Vary: "Accept-Encoding",
}, },
}); });
} }
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
}); });
} }
app.listen(PORT); app.listen(PORT);
console.log( console.log(
`🚀 Server running at http://localhost:${PORT} in ${isProduction ? "production" : "development"} mode`, `🚀 Server running at http://localhost:${PORT} in ${isProduction ? "production" : "development"} mode`,
); );
export type ApiApp = typeof app; export type ApiApp = typeof app;

View File

@@ -1,11 +1,10 @@
import { import { AppShell, Burger, Group, useMantineColorScheme } from "@mantine/core";
AppShell,
Burger,
Group,
useMantineColorScheme,
} from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router"; import {
createFileRoute,
Outlet,
useRouterState,
} from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Sidebar } from "@/components/sidebar"; import { Sidebar } from "@/components/sidebar";
@@ -44,12 +43,7 @@ function PengaturanLayout() {
> >
<AppShell.Header bg={headerBgColor}> <AppShell.Header bg={headerBgColor}>
<Group h="100%" px="lg" align="center" wrap="nowrap"> <Group h="100%" px="lg" align="center" wrap="nowrap">
<Burger <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<Header /> <Header />
</Group> </Group>
</AppShell.Header> </AppShell.Header>