baru ni e

This commit is contained in:
bipproduction
2025-12-11 15:52:22 +08:00
commit eb5eee6ae9
52 changed files with 5774 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="postgresql://bip:Production_123@localhost:5432/akuapa?schema=public"
JWT_SECRET=super_sangat_rahasia_sekali
BUN_PUBLIC_BASE_URL=http://localhost:3000
PORT=3000

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Prisma generated client
generated/
# nextpush
nextpush
nextpush/

138
README.md Normal file
View File

@@ -0,0 +1,138 @@
# Bun React Template Starter
This template is a starting point for building modern full-stack web applications using Bun, React, ElysiaJS, and Prisma. This project is designed to provide a fast, efficient, and structured development experience with a cutting-edge technology stack.
## Key Features
- **Super-Fast Runtime**: Built on top of [Bun](https://bun.sh/), a high-performance JavaScript runtime.
- **End-to-End Typesafe Backend**: Utilizes [ElysiaJS](https://elysiajs.com/) for a type-safe API from the backend to the frontend.
- **Automatic API Documentation**: Comes with [Elysia Swagger](https://elysiajs.com/plugins/swagger) to automatically generate interactive API documentation.
- **Modern Frontend**: A feature-rich and customizable user interface using [React](https://react.dev/) and [Mantine UI](https://mantine.dev/).
- **Easy Database Access**: Integrated with [Prisma](https://www.prisma.io/) as an ORM for intuitive and secure database interactions.
- **Clear Project Structure**: Logical file and folder organization to facilitate easy navigation and development.
## Tech Stack
- **Runtime**: Bun
- **Backend**:
- **Framework**: ElysiaJS
- **ElysiaJS Modules**:
- `@elysiajs/cors`: Manages Cross-Origin Resource Sharing policies.
- `@elysiajs/jwt`: JSON Web Token-based authentication.
- `@elysiajs/swagger`: Creates API documentation (Swagger/OpenAPI).
- `@elysiajs/eden`: A typesafe RPC-like client to connect the frontend with the Elysia API.
- **Frontend**:
- **Library**: React
- **UI Framework**: Mantine
- **Routing**: React Router
- **Data Fetching**: SWR
- **Database**:
- **ORM**: Prisma
- **Supported Databases**: PostgreSQL (default), MySQL, SQLite, etc.
- **Language**: TypeScript
## Getting Started
### 1. Clone the Repository
```bash
git clone https://github.com/your-username/bun-react-template-starter.git
cd bun-react-template-starter
```
### 2. Install Dependencies
Ensure you have [Bun](https://bun.sh/docs/installation) installed. Then, run the following command:
```bash
bun install
```
### 3. Configure Environment Variables
Copy the `.env.example` file to `.env` and customize the values.
```bash
cp .env.example .env
```
Fill in your `.env` file similar to the example below:
```
DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
JWT_SECRET=a_super_long_and_secure_secret
BUN_PUBLIC_BASE_URL=http://localhost:3000
PORT=3000
```
After that, create TypeScript type declarations for your environment variables with the provided script:
```bash
bun run generate:env
```
This command will generate a `types/env.d.ts` file based on your `.env`.
### 4. Database Preparation
Make sure your PostgreSQL database server is running. Then, apply the Prisma schema to your database:
```bash
bunx prisma db push
```
You can also seed the database with initial data using the following script:
```bash
bun run seed
```
### 5. Running the Development Server
```bash
bun run dev
```
The application will be running at `http://localhost:3000`. The server supports hot-reloading, so changes in the code will be reflected instantly without needing a manual restart.
### 6. Accessing API Documentation (Swagger)
Once the server is running, you can access the automatically generated API documentation at:
`http://localhost:3000/swagger`
## Available Scripts
- `bun run dev`: Runs the development server with hot-reloading.
- `bun run build`: Builds the frontend application for production into the `dist` directory.
- `bun run start`: Runs the application in production mode.
- `bun run seed`: Executes the database seeding script located in `prisma/seed.ts`.
- `bun run generate:route`: A utility to create new route files in the backend.
- `bun run generate:env`: Generates a type definition file (`.d.ts`) from the variables in `.env`.
## Project Structure
```
/
├── bin/ # Utility scripts (generators)
├── prisma/ # Database schema, migrations, and seed
├── src/ # Main source code
│ ├── App.tsx # Root application component
│ ├── clientRoutes.ts # Route definitions for the frontend
│ ├── frontend.tsx # Entry point for client-side rendering (React)
│ ├── index.css # Global CSS file
│ ├── index.html # Main HTML template
│ ├── index.tsx # Main entry point for the app (server and client)
│ ├── components/ # Reusable React components
│ ├── lib/ # Shared libraries/helpers (e.g., apiFetch)
│ ├── pages/ # React page components
│ └── server/ # Backend code (ElysiaJS)
│ ├── lib/ # Server-specific libraries (e.g., prisma client)
│ ├── middlewares/ # Middleware for the API
│ └── routes/ # API route files
└── types/ # TypeScript type definitions
```
## Contributing
Contributions are highly welcome! Please feel free to create a pull request to add features, fix bugs, or improve the documentation.

53
bin/env.generate.ts Normal file
View File

@@ -0,0 +1,53 @@
import * as fs from "fs";
import * as path from "path";
import * as dotenv from "dotenv";
interface GenerateEnvTypesOptions {
envFilePath?: string;
outputDir?: string;
outputFileName?: string;
}
export function generateEnvTypes(options: GenerateEnvTypesOptions = {}) {
const {
envFilePath = path.resolve(process.cwd(), ".env"),
outputDir = path.resolve(process.cwd(), "types"),
outputFileName = "env.d.ts",
} = options;
const outputFile = path.join(outputDir, outputFileName);
// 1. Baca .env
if (!fs.existsSync(envFilePath)) {
console.warn(`⚠️ .env file not found at: ${envFilePath}`);
return;
}
const envContent = fs.readFileSync(envFilePath, "utf-8");
const parsed = dotenv.parse(envContent);
// 2. Generate TypeScript declare
const lines = Object.keys(parsed).map((key) => ` ${key}?: string;`);
const fileContent = `declare namespace NodeJS {
interface ProcessEnv {
${lines.join("\n")}
}
}
`;
// 3. Buat folder kalau belum ada
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 4. Tulis file
fs.writeFileSync(outputFile, fileContent, "utf-8");
console.log(`✅ Env types generated at: ${outputFile}`);
}
if (import.meta.main) {
generateEnvTypes();
}

416
bin/route.generate.ts Normal file
View File

@@ -0,0 +1,416 @@
#!/usr/bin/env bun
import fs from "fs";
import path from "path";
import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import * as t from "@babel/types";
import { readdirSync, statSync, writeFileSync } from "fs";
import _ from "lodash";
import { basename, extname, join, relative } from "path";
const PAGES_DIR = join(process.cwd(), "src/pages");
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
/******************************
* Prefetch Helper Template
******************************/
const PREFETCH_HELPER = `
/**
* Prefetch lazy component:
* - Hover
* - Visible (viewport)
* - Browser idle
*/
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
if (!el) return;
let done = false;
const run = () => {
if (done) return;
done = true;
preload();
};
// 1) On hover
el.addEventListener("pointerenter", run, { once: true });
// 2) On visible (IntersectionObserver)
const io = new IntersectionObserver((entries) => {
if (entries && entries[0] && entries[0].isIntersecting) {
run();
io.disconnect();
}
});
io.observe(el);
// 3) On idle
if ("requestIdleCallback" in window) {
requestIdleCallback(() => run());
} else {
setTimeout(run, 200);
}
}
`;
/******************************
* Component Name Generator
******************************/
const toComponentName = (fileName: string): string =>
fileName
.replace(/\.[^/.]+$/, "")
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase())
.replace(/\s+/g, "");
/******************************
* Route Path Normalizer
******************************/
function toRoutePath(name: string): string {
name = name.replace(/\.[^/.]+$/, "");
if (name.toLowerCase() === "home") return "/";
if (name.toLowerCase() === "login") return "/login";
if (name.toLowerCase() === "notfound") return "/*";
if (name.startsWith("[") && name.endsWith("]"))
return `:${name.slice(1, -1)}`;
name = name.replace(/_page$/i, "").replace(/^form_/i, "");
return _.kebabCase(name);
}
/******************************
* Scan Folder + Validation + Dynamic Duplicate Check
******************************/
function scan(dir: string): any[] {
const items = readdirSync(dir);
const routes: any[] = [];
const dynamicParams = new Set<string>();
for (const item of items) {
const full = join(dir, item);
const stat = statSync(full);
if (stat.isDirectory()) {
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
console.warn(`⚠️ Invalid folder name: ${item}`);
}
routes.push({
name: item,
path: _.kebabCase(item),
children: scan(full),
});
} else if (extname(item) === ".tsx") {
const base = basename(item, ".tsx");
if (!/^[a-zA-Z0-9_[\]-]+$/.test(base)) {
console.warn(`⚠️ Invalid file name: ${item}`);
}
if (base.startsWith("[") && base.endsWith("]")) {
const p = base.slice(1, -1);
if (dynamicParams.has(p)) {
console.error(`❌ Duplicate dynamic param "${p}" in ${dir}`);
process.exit(1);
}
dynamicParams.add(p);
}
routes.push({
name: base,
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
});
}
}
return routes;
}
/******************************
* Index Detection
******************************/
function findIndexFile(folderName: string, children: any[]) {
const lower = folderName.toLowerCase();
return (
children.find((r: any) => r.name.toLowerCase().endsWith("_home")) ||
children.find((r: any) => r.name.toLowerCase() === "index") ||
children.find((r: any) => r.name.toLowerCase() === `${lower}_page`)
);
}
/******************************
* Generate JSX <Route> (Lazy + Prefetch)
******************************/
function generateJSX(routes: any[], parentPath = ""): string {
let jsx = "";
for (const route of routes) {
if (route.children) {
const layout = route.children.find((r: any) =>
r.name.endsWith("_layout")
);
if (layout) {
const LayoutComp = toComponentName(
layout.name.replace("_layout", "Layout")
);
const nested = route.children.filter((x: any) => x !== layout);
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
const indexFile = findIndexFile(route.name, route.children);
const indexRoute = indexFile
? `<Route index element={<${toComponentName(
indexFile.name
)}.Component />} />`
: `<Route index element={<Navigate to="${(
parentPath +
"/" +
route.path +
"/" +
(nested[0]?.name ?? "")
).replace(/\/+/g, "/")}" replace />}/>`;
jsx += `
<Route path="${parentPath}/${route.path}" element={<${LayoutComp}.Component />}>
${indexRoute}
${nestedRoutes}
</Route>
`;
} else {
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
}
} else {
const Comp = toComponentName(route.name);
const routePath = toRoutePath(route.name);
const fullPath = routePath.startsWith("/")
? routePath
: `${parentPath}/${routePath}`.replace(/\/+/g, "/");
jsx += `
<Route
path="${fullPath}"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<${Comp}.Component />
</React.Suspense>
}
/>
`;
}
}
return jsx;
}
/******************************
* Lazy Import + Prefetch Injection
******************************/
function generateImports(routes: any[]): string {
const list: string[] = [];
function walk(rs: any[]) {
for (const r of rs) {
if (r.children) walk(r.children);
else {
const C = toComponentName(r.name);
const file = r.filePath.replace(/\.tsx$/, "");
list.push(`
const ${C} = {
Component: React.lazy(() => import("./${file}")),
preload: () => import("./${file}")
};
`);
}
}
}
walk(routes);
return list.join("\n");
}
/******************************
* Generate AppRoutes.tsx
******************************/
function generateRoutes() {
const allRoutes = scan(PAGES_DIR);
const imports = generateImports(allRoutes);
const jsx = generateJSX(allRoutes);
let loadingSkeleton = `
const SkeletonLoading = () => {
return (
<div style={{ padding: "20px" }}>
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
))}
</div>
);
};
`
const final = `
// ⚡ AUTO-GENERATED — DO NOT EDIT
import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Skeleton } from "@mantine/core";
${loadingSkeleton}
${PREFETCH_HELPER}
${imports}
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
${jsx}
</Routes>
</BrowserRouter>
);
}
`;
writeFileSync(OUTPUT_FILE, final);
console.log(`✅ Routes generated → ${OUTPUT_FILE}`);
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]);
}
/******************************
* Extract flat client routes
******************************/
const SRC_DIR = path.resolve("src");
const APP_ROUTES_FILE = join(SRC_DIR, "AppRoutes.tsx");
interface RouteNode {
path: string;
children: RouteNode[];
}
function getAttributePath(attrs: any[]) {
const attr = attrs.find(
(a) => t.isJSXAttribute(a) && a.name.name === "path"
) as any;
return attr?.value?.value ?? "";
}
function extractRouteNodes(node: t.JSXElement): RouteNode | null {
const op = node.openingElement;
if (!t.isJSXIdentifier(op.name) || op.name.name !== "Route") return null;
const cur = getAttributePath(op.attributes);
const children: RouteNode[] = [];
for (const c of node.children) {
if (t.isJSXElement(c)) {
const n = extractRouteNodes(c);
if (n) children.push(n);
}
}
return { path: cur, children };
}
function flattenRoutes(node: RouteNode, parent = ""): Record<string, string> {
const r: Record<string, string> = {};
let full = node.path;
if (full) {
if (!full.startsWith("/"))
full =
parent && full !== "/"
? `${parent.replace(/\/$/, "")}/${full}`
: "/" + full;
full = full.replace(/\/+/g, "/");
r[full] = full;
}
for (const c of node.children)
Object.assign(r, flattenRoutes(c, full || parent));
return r;
}
function extractRoutes(code: string) {
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
const routes: Record<string, string> = {};
traverse(ast, {
JSXElement(p) {
const op = p.node.openingElement;
if (t.isJSXIdentifier(op.name) && op.name.name === "Routes") {
for (const c of p.node.children) {
if (t.isJSXElement(c)) {
const root = extractRouteNodes(c);
if (root) Object.assign(routes, flattenRoutes(root));
}
}
}
},
});
return routes;
}
/******************************
* Type-Safe Route Builder
******************************/
function generateTypeSafe(routes: Record<string, string>) {
const keys = Object.keys(routes).filter((x) => !x.includes("*"));
const union = keys.map((x) => `"${x}"`).join(" | ");
const code = `
export type AppRoute = ${union};
export function route(path: AppRoute, params?: Record<string,string|number>) {
if (!params) return path;
let final = path;
for (const k of Object.keys(params)) {
final = final.replace(":" + k, params[k] + "") as AppRoute;
}
return final;
}
`;
fs.writeFileSync(join(SRC_DIR, "routeTypes.ts"), code);
console.log("📄 routeTypes.ts generated.");
}
/******************************
* MAIN
******************************/
export default function run() {
generateRoutes();
const code = fs.readFileSync(APP_ROUTES_FILE, "utf-8");
const routes = extractRoutes(code);
const out = join(SRC_DIR, "clientRoutes.ts");
fs.writeFileSync(
out,
`// AUTO-GENERATED\nconst clientRoutes = ${JSON.stringify(
routes,
null,
2
)} as const;\nexport default clientRoutes;`
);
console.log(`📄 clientRoutes.ts saved → ${out}`);
generateTypeSafe(routes);
}
run();

17
bun-env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

528
bun.lock Normal file
View File

@@ -0,0 +1,528 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.5",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.10",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@prisma/client": "^6.19.0",
"@tabler/icons-react": "^3.35.0",
"add": "^2.0.6",
"adhan": "^4.4.3",
"bun": "^1.3.4",
"date-holidays": "^3.26.5",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"elysia": "^1.4.16",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "^19.2.0",
"react-date-wheel-picker": "^0.1.12",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"swr": "^2.3.6",
"zod": "^4.1.13",
},
"devDependencies": {
"@babel/parser": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "latest",
"@types/jwt-decode": "^3.1.0",
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, ""],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, ""],
"@elysiajs/eden": ["@elysiajs/eden@1.4.5", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-hIOeH+S5NU/84A7+t8yB1JjxqjmzRkBF9fnLn6y+AH8EcF39KumOAnciMhIOkhhThVZvXZ3d+GsizRc+Fxoi8g=="],
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.0", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, ""],
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, ""],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, ""],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, ""],
"@floating-ui/react": ["@floating-ui/react@0.27.16", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, ""],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, ""],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, ""],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mantine/core": ["@mantine/core@8.3.9", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-ivj0Crn5N521cI2eWZBsBGckg0ZYRqfOJz5vbbvYmfj65bp0EdsyqZuOxXzIcn2aUScQhskfvzyhV5XIUv81PQ=="],
"@mantine/dates": ["@mantine/dates@8.3.10", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.10", "@mantine/hooks": "8.3.10", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-P1uZ+alYGp7fsmkfd+7Fur4AGrqT0X6BWLiVTomzrbyykA+m4TSwPyQjKfsDc7XRqaqx992br/U65T82zy+qGQ=="],
"@mantine/hooks": ["@mantine/hooks@8.3.9", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-Dfz7W0+K1cq4Gb1WFQCZn8tsMXkLH6MV409wZR/ToqsxdNDUMJ/xxbfnwEXWEZjXNJd1wDETHgc+cZG2lTe3Xw=="],
"@mantine/modals": ["@mantine/modals@8.3.9", "", { "peerDependencies": { "@mantine/core": "8.3.9", "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-0WOikHgECJeWA/1TNf+sxOnpNwQjmpyph3XEhzFkgneimW6Ry7R6qd/i345CDLSu6kP6FGGRI73SUROiTcu2Ng=="],
"@mantine/notifications": ["@mantine/notifications@8.3.9", "", { "dependencies": { "@mantine/store": "8.3.9", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.9", "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-emUdoCyaccf/NuNmJ4fQgloJ7hEod0Pde7XIoD9xUUztVchL143oWRU2gYm6cwqzSyjpjTaqPXfz5UvEBRYjZw=="],
"@mantine/store": ["@mantine/store@8.3.9", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-Z4tYW597mD3NxHLlJ3OJ1aKucmwrD9nhqobz+142JNw01aHqzKjxVXlu3L5GGa7F3u3OjXJk/qb1QmUs4sU+Jw=="],
"@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Ie4jDGvNGuPSD+pyyBKL8dJmX+bZfDNYEalwgROImVtwB1XYAatJK20dMaRlPA7jOhjvS9Io+4IZAJu7Js0AA=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4/BJojT8hk5g6Gecjn5yI7y96/+9Mtzsvdp9+2dcy9sTMdlV7jBvDzswqyJPZyQqw0F3HV3Vu9XuMubZwKd9lA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZYxzIOCDqylTMsnWYERjKMMuK2b4an4qbloBmUZTwLHmVzos00yrhtpitZhJBgH6yB/l4Q5eoJ2W98UKtFFeiQ=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8DUIlanftMdFxLGq2FxwKwfrp8O4ZofF/8Oc6lxCyEFmg2hixbHhL04+fPfJIi5D4hZloynxZdwTeDbGv/Kc4A=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6UtmM4wXgRKz+gnLZEfddfsuBSVQpJr09K12e5pbdnLzeWgXYlBT5FG8S7SVn1t6cbgBMnigEsFjWwfTuMNoCw=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-03iSDMqdrmIFAsvsRptq+A7EGNjkg20dNzPnqxAlXHk5rc1PeIRWIP0eIn0i3nI6mmdj33mimf9AGr0+d0lKMg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ZMGPbFPqmG/VYJv61D+Y1V7T23jPK57vYl7yYLakmkTRjG6vcJ0Akhb2qR1iW94rHvfEBjeuVDAZBp8Qp9oyWA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-xUXPuJHndGhk4K3Cx1FgTyTgDZOn+ki3eWvdXYqKdfi0EaNA9KpUq+/vUtpJbZRjzpHs9L+OJcdDILq5H0LX4g=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qsGSSlNsxiX8lAayK2uYCfMLtqu776F0nn7qoyzg9Ti7mElM3woNh7RtGClTwQ6qsp5/UvgqT9g4pLaDHmqJFg=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nswsuN6+HZPim6x4tFpDFpMa/qpTKfywbGvCkzxwrbJO9MtpuW/54NA1nFbHhpV14OLU0xuxyBj2PK4FHq4MlA=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ZQiSDFfSUdOrPTiL2GvkxlC/kMED4fsJwdZnwJK6S9ylXnk9xY/9ZXfe1615SFLQl2LsVRzJAtjQLeM0BifIKQ=="],
"@prisma/client": ["@prisma/client@6.19.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g=="],
"@prisma/config": ["@prisma/config@6.19.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg=="],
"@prisma/debug": ["@prisma/debug@6.19.0", "", {}, "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA=="],
"@prisma/engines": ["@prisma/engines@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/fetch-engine": "6.19.0", "@prisma/get-platform": "6.19.0" } }, "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "", {}, "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/get-platform": "6.19.0" } }, "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ=="],
"@prisma/get-platform": ["@prisma/get-platform@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, ""],
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, ""],
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, ""],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, ""],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tabler/icons": ["@tabler/icons@3.35.0", "", {}, "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ=="],
"@tabler/icons-react": ["@tabler/icons-react@3.35.0", "", { "dependencies": { "@tabler/icons": "3.35.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/jwt-decode": ["@types/jwt-decode@3.1.0", "", { "dependencies": { "jwt-decode": "*" } }, "sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w=="],
"@types/lodash": ["@types/lodash@4.17.21", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="],
"@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, ""],
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""],
"add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="],
"adhan": ["adhan@4.4.3", "", {}, "sha512-568KkQd8OMLUj7o7+d2FDcm6vZHWQrE7vsm/Evssh8sfUDpPyaboj3PVsScZAr7L7sNRgPrtLMmDZZfM7VeAYw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astronomia": ["astronomia@4.2.0", "", {}, "sha512-mTvpBGyXB80aSsDhAAiuwza5VqAyqmj5yzhjBrFhRy17DcWDzJrb8Vdl4Sm+g276S+mY7bk/5hi6akZ5RQFeHg=="],
"bun": ["bun@1.3.4", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.4", "@oven/bun-darwin-x64": "1.3.4", "@oven/bun-darwin-x64-baseline": "1.3.4", "@oven/bun-linux-aarch64": "1.3.4", "@oven/bun-linux-aarch64-musl": "1.3.4", "@oven/bun-linux-x64": "1.3.4", "@oven/bun-linux-x64-baseline": "1.3.4", "@oven/bun-linux-x64-musl": "1.3.4", "@oven/bun-linux-x64-musl-baseline": "1.3.4", "@oven/bun-windows-x64": "1.3.4", "@oven/bun-windows-x64-baseline": "1.3.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-xV6KgD5ImquuKsoghzbWmYzeCXmmSgN6yJGz444hri2W+NGKNRFUNrEhy9+/rRXbvNA2qF0K0jAwqFNy1/GhBg=="],
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"caldate": ["caldate@2.0.5", "", { "dependencies": { "moment-timezone": "^0.5.43" } }, "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, ""],
"caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, ""],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cookie": ["cookie@1.0.2", "", {}, ""],
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, ""],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-bengali-revised": ["date-bengali-revised@2.0.2", "", {}, "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg=="],
"date-chinese": ["date-chinese@2.1.4", "", { "dependencies": { "astronomia": "^4.1.0" } }, "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww=="],
"date-easter": ["date-easter@1.0.3", "", {}, "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ=="],
"date-holidays": ["date-holidays@3.26.5", "", { "dependencies": { "date-holidays-parser": "^3.4.7", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "prepin": "^1.0.3" }, "bin": { "holidays2json": "scripts/holidays2json.cjs" } }, "sha512-I/nGVQAxBmLFxjwE48vWTOlZa7nKucQifHJHbkfveCco0hwE+3GvvAPYMw4iXBldvDqaruujV5AvrbOjq9UNUg=="],
"date-holidays-parser": ["date-holidays-parser@3.4.7", "", { "dependencies": { "astronomia": "^4.1.1", "caldate": "^2.0.5", "date-bengali-revised": "^2.0.2", "date-chinese": "^2.1.4", "date-easter": "^1.0.3", "deepmerge": "^4.3.1", "jalaali-js": "^1.2.7", "moment-timezone": "^0.5.47" } }, "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, ""],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
"elysia": ["elysia@1.4.16", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"exact-mirror": ["exact-mirror@0.2.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, ""],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, ""],
"file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, ""],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"hookable": ["hookable@5.5.3", "", {}, ""],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"jalaali-js": ["jalaali-js@1.2.8", "", {}, "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.0", "", {}, ""],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, ""],
"next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, ""],
"pathe": ["pathe@1.1.2", "", {}, ""],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, ""],
"picomatch": ["picomatch@4.0.3", "", {}, ""],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, ""],
"postcss-mixins": ["postcss-mixins@12.1.2", "", { "dependencies": { "postcss-js": "^4.0.1", "postcss-simple-vars": "^7.0.1", "sugarss": "^5.0.0", "tinyglobby": "^0.2.14" }, "peerDependencies": { "postcss": "^8.2.14" } }, ""],
"postcss-nested": ["postcss-nested@7.0.2", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.2.14" } }, ""],
"postcss-preset-mantine": ["postcss-preset-mantine@1.18.0", "", { "dependencies": { "postcss-mixins": "^12.0.0", "postcss-nested": "^7.0.2" }, "peerDependencies": { "postcss": ">=8.0.0" } }, ""],
"postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, ""],
"postcss-simple-vars": ["postcss-simple-vars@7.0.1", "", { "peerDependencies": { "postcss": "^8.2.1" } }, ""],
"prepin": ["prepin@1.0.3", "", { "bin": { "prepin": "./bin/prepin.js" } }, "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA=="],
"prisma": ["prisma@6.19.0", "", { "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@19.2.0", "", {}, ""],
"react-date-wheel-picker": ["react-date-wheel-picker@0.1.12", "", { "dependencies": { "next": "15.3.4", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-JFtpyje50mpYAfENCeQfObeAxirrbPlAs3iHe2U43SRimADCwoTGblJrVeRgzKzW3sQGH5Um85rapKxQySw4FQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, ""],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-number-format": ["react-number-format@5.4.4", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="],
"react-router-dom": ["react-router-dom@7.9.6", "", { "dependencies": { "react-router": "7.9.6" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"scheduler": ["scheduler@0.27.0", "", {}, ""],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, ""],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, ""],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, ""],
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
"tabbable": ["tabbable@6.2.0", "", {}, ""],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"tslib": ["tslib@2.8.1", "", {}, ""],
"type-fest": ["type-fest@4.41.0", "", {}, ""],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.14.0", "", {}, ""],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, ""],
"zhead": ["zhead@2.2.4", "", {}, ""],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, ""],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, ""],
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": "bin/nanoid.js" }, ""],
"@scalar/themes/@scalar/types/zod": ["zod@3.25.76", "", {}, ""],
}
}

2
bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[serve.static]
env = "BUN_PUBLIC_*"

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "kegiatanku",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx",
"seed": "bun prisma/seed.ts",
"generate:route": "bun bin/route.generate.ts",
"generate:env": "bun bin/env.generate.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.5",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.10",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@prisma/client": "^6.19.0",
"@tabler/icons-react": "^3.35.0",
"add": "^2.0.6",
"adhan": "^4.4.3",
"bun": "^1.3.4",
"date-holidays": "^3.26.5",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"elysia": "^1.4.16",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "^19.2.0",
"react-date-wheel-picker": "^0.1.12",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"swr": "^2.3.6",
"zod": "^4.1.13"
},
"devDependencies": {
"prisma": "^6.19.0",
"@types/bun": "latest",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@types/lodash": "^4.17.21",
"@types/jwt-decode": "^3.1.0",
"@babel/parser": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@types/babel__traverse": "^7.28.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1"
}
}

16
postcss.config.js Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

48
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,48 @@
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ApiKey ApiKey[]
configs Configs? @relation(fields: [configsId], references: [id])
configsId String?
role USER_ROLE @default(USER)
active Boolean @default(true)
}
model ApiKey {
id String @id @default(cuid())
User User? @relation(fields: [userId], references: [id])
userId String
name String
key String @unique @db.Text
description String?
expiredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Configs {
id String @id @default("1")
allowRegister Boolean @default(false)
imamKey String @default("imam_key")
ikomahKey String @default("ikomah_key")
selectedUser User[]
}
enum USER_ROLE {
ADMIN
USER
}

74
prisma/seed.ts Normal file
View File

@@ -0,0 +1,74 @@
import { prisma } from "@/server/lib/prisma";
import { USER_ROLE } from "generated/prisma";
const user = [
{
name: "Bip",
email: "wibu@bip.com",
password: "Production_123",
role: USER_ROLE.ADMIN
},
{
name: "Jun",
email: "jun@bip.com",
password: "Production_123",
role: USER_ROLE.USER
},
{
name: "Malik",
email: "malik@bip.com",
password: "Production_123",
role: USER_ROLE.USER
},
{
name: "Bagas",
email: "bagas@bip.com",
password: "Production_123",
role: USER_ROLE.USER
},
{
name: "Nico",
email: "nico@bip.com",
password: "Production_123",
role: USER_ROLE.USER
},
{
name: "Keano",
email: "keano@bip.com",
password: "Production_123",
role: USER_ROLE.USER
}
];
const configs = {
allowRegister: false,
imamKey: "imam",
ikomahKey: "ikomah"
}
; (async () => {
for (const u of user) {
await prisma.user.upsert({
where: { email: u.email },
create: { ...u },
update: { ...u },
})
console.log(`✅ User ${u.email} seeded successfully`)
}
await prisma.configs.upsert({
where: { id: "1" },
create: configs,
update: configs,
})
console.log(`✅ Configs seeded successfully`)
})().catch((e) => {
console.error(e)
process.exit(1)
}).finally(() => {
console.log("✅ Seeding completed successfully ")
process.exit(0)
})

19
src/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Notifications } from "@mantine/notifications";
import { ModalsProvider } from "@mantine/modals";
import { MantineProvider } from "@mantine/core";
import AppRoutes from "./AppRoutes";
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css";
export function App() {
return (
<MantineProvider defaultColorScheme="dark">
<Notifications />
<ModalsProvider>
<AppRoutes />
</ModalsProvider>
</MantineProvider>
);
}

258
src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,258 @@
// ⚡ AUTO-GENERATED — DO NOT EDIT
import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Skeleton } from "@mantine/core";
const SkeletonLoading = () => {
return (
<div style={{ padding: "20px" }}>
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
))}
</div>
);
};
/**
* Prefetch lazy component:
* - Hover
* - Visible (viewport)
* - Browser idle
*/
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
if (!el) return;
let done = false;
const run = () => {
if (done) return;
done = true;
preload();
};
// 1) On hover
el.addEventListener("pointerenter", run, { once: true });
// 2) On visible (IntersectionObserver)
const io = new IntersectionObserver((entries) => {
if (entries && entries[0] && entries[0].isIntersecting) {
run();
io.disconnect();
}
});
io.observe(el);
// 3) On idle
if ("requestIdleCallback" in window) {
requestIdleCallback(() => run());
} else {
setTimeout(run, 200);
}
}
const ShalatLayout = {
Component: React.lazy(() => import("./pages/shalat/shalat_layout")),
preload: () => import("./pages/shalat/shalat_layout"),
};
const ShalatPage = {
Component: React.lazy(() => import("./pages/shalat/shalat_page")),
preload: () => import("./pages/shalat/shalat_page"),
};
const Login = {
Component: React.lazy(() => import("./pages/Login")),
preload: () => import("./pages/Login"),
};
const Home = {
Component: React.lazy(() => import("./pages/Home")),
preload: () => import("./pages/Home"),
};
const Register = {
Component: React.lazy(() => import("./pages/Register")),
preload: () => import("./pages/Register"),
};
const DashboardShalatPage = {
Component: React.lazy(
() => import("./pages/dashboard/shalat/dashboard-shalat_page"),
),
preload: () => import("./pages/dashboard/shalat/dashboard-shalat_page"),
};
const DashboardShalatLayout = {
Component: React.lazy(
() => import("./pages/dashboard/shalat/dashboard-shalat_layout"),
),
preload: () => import("./pages/dashboard/shalat/dashboard-shalat_layout"),
};
const ConfigLayout = {
Component: React.lazy(() => import("./pages/dashboard/config/config_layout")),
preload: () => import("./pages/dashboard/config/config_layout"),
};
const ConfigPage = {
Component: React.lazy(() => import("./pages/dashboard/config/config_page")),
preload: () => import("./pages/dashboard/config/config_page"),
};
const ApikeyPage = {
Component: React.lazy(() => import("./pages/dashboard/apikey/apikey_page")),
preload: () => import("./pages/dashboard/apikey/apikey_page"),
};
const DashboardPage = {
Component: React.lazy(() => import("./pages/dashboard/dashboard_page")),
preload: () => import("./pages/dashboard/dashboard_page"),
};
const DashboardLayout = {
Component: React.lazy(() => import("./pages/dashboard/dashboard_layout")),
preload: () => import("./pages/dashboard/dashboard_layout"),
};
const ProfileLayout = {
Component: React.lazy(() => import("./pages/profile/Profile_layout")),
preload: () => import("./pages/profile/Profile_layout"),
};
const ProfilePage = {
Component: React.lazy(() => import("./pages/profile/Profile_page")),
preload: () => import("./pages/profile/Profile_page"),
};
const NotFound = {
Component: React.lazy(() => import("./pages/NotFound")),
preload: () => import("./pages/NotFound"),
};
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
<Route path="/shalat" element={<ShalatLayout.Component />}>
<Route index element={<ShalatPage.Component />} />
<Route
path="/shalat/shalat"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ShalatPage.Component />
</React.Suspense>
}
/>
</Route>
<Route
path="/login"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Login.Component />
</React.Suspense>
}
/>
<Route
path="/"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Home.Component />
</React.Suspense>
}
/>
<Route
path="/register"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Register.Component />
</React.Suspense>
}
/>
<Route path="/dashboard" element={<DashboardLayout.Component />}>
<Route index element={<DashboardPage.Component />} />
<Route
path="/dashboard/shalat"
element={<DashboardShalatLayout.Component />}
>
<Route
index
element={
<Navigate
to="/dashboard/shalat/dashboard-shalat_page"
replace
/>
}
/>
<Route
path="/dashboard/shalat/dashboard-shalat"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<DashboardShalatPage.Component />
</React.Suspense>
}
/>
</Route>
<Route path="/dashboard/config" element={<ConfigLayout.Component />}>
<Route index element={<ConfigPage.Component />} />
<Route
path="/dashboard/config/config"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ConfigPage.Component />
</React.Suspense>
}
/>
</Route>
<Route
path="/dashboard/apikey/apikey"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ApikeyPage.Component />
</React.Suspense>
}
/>
<Route
path="/dashboard/dashboard"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<DashboardPage.Component />
</React.Suspense>
}
/>
</Route>
<Route path="/profile" element={<ProfileLayout.Component />}>
<Route index element={<ProfilePage.Component />} />
<Route
path="/profile/profile"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ProfilePage.Component />
</React.Suspense>
}
/>
</Route>
<Route
path="/*"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<NotFound.Component />
</React.Suspense>
}
/>
</Routes>
</BrowserRouter>
);
}

719
src/Landing.tsx Normal file
View File

@@ -0,0 +1,719 @@
import clientRoutes from "./clientRoutes";
export function LandingPage() {
return (
<html lang="id">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jadwal Sholat & Imam Semua dalam Satu Genggaman</title>
<style>{`
:root{
--bg-1: #0f172a;
--accent: #4F46E5;
--accent-2: #7c3aed;
--glass: rgba(255,255,255,0.06);
--muted: rgba(255,255,255,0.85);
--card: rgba(255,255,255,0.04);
--glass-strong: rgba(255,255,255,0.08);
--radius-lg: 16px;
--radius-sm: 8px;
--max-w: 1200px;
--glass-border: rgba(255,255,255,0.06);
--shadow: 0 10px 30px rgba(2,6,23,0.6);
color-scheme: dark;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body,#root{height:100%}
body{
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
background:
radial-gradient(1200px 600px at 10% 10%, rgba(124,58,237,0.12), transparent 8%),
radial-gradient(1000px 400px at 90% 90%, rgba(79,70,229,0.10), transparent 8%),
linear-gradient(180deg, #071024 0%, #071229 40%, #08162f 100%);
color: var(--muted);
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
overflow-x:hidden;
padding-bottom:80px;
}
.container{
max-width:var(--max-w);
margin:0 auto;
padding:0 20px;
width:100%;
}
/* NAVBAR */
nav{
position:sticky;
top:0;
z-index:60;
backdrop-filter: blur(8px);
background: linear-gradient(180deg, rgba(7,10,20,0.6), rgba(7,10,20,0.35));
border-bottom: 1px solid var(--glass-border);
padding:14px 0;
}
.nav-row{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
}
.brand{
display:flex;
align-items:center;
gap:12px;
}
.logo{
width:44px;
height:44px;
border-radius:10px;
background: linear-gradient(135deg,var(--accent),var(--accent-2));
display:grid;
place-items:center;
font-weight:700;
color:white;
box-shadow: var(--shadow);
font-family: Inter, sans-serif;
}
.brand-title{
font-weight:700;
font-size:16px;
letter-spacing:0.2px;
color:white;
}
.nav-links{
display:flex;
gap:18px;
align-items:center;
}
.nav-links a{
color:var(--muted);
text-decoration:none;
font-weight:600;
font-size:14px;
padding:8px 10px;
border-radius:8px;
}
.nav-links a:hover{background:var(--glass-strong)}
.cta{
padding:10px 18px;
background: linear-gradient(90deg,var(--accent),var(--accent-2));
color:white;
border-radius:12px;
font-weight:700;
text-decoration:none;
box-shadow: 0 8px 30px rgba(79,70,229,0.18);
display:inline-flex;
gap:10px;
align-items:center;
}
/* HERO */
.hero{
padding:100px 0 60px;
display:grid;
grid-template-columns: 1fr 420px;
gap:36px;
align-items:center;
}
.hero-left h1{
font-size:40px;
line-height:1.05;
margin-bottom:12px;
color: #fff;
font-weight:800;
}
.kicker{
display:inline-block;
padding:6px 12px;
border-radius:999px;
background:rgba(255,255,255,0.04);
color: #e6e9ff;
font-weight:700;
margin-bottom:18px;
font-size:13px;
}
.hero-lead{
font-size:18px;
opacity:0.95;
margin-bottom:22px;
max-width:680px;
}
.hero-ctas{display:flex;gap:12px;flex-wrap:wrap}
.btn-primary{
background: linear-gradient(90deg,var(--accent),var(--accent-2));
color:white;
padding:12px 20px;
border-radius:12px;
font-weight:700;
text-decoration:none;
display:inline-flex;
gap:10px;
align-items:center;
box-shadow: 0 10px 30px rgba(79,70,229,0.14);
}
.btn-ghost{
background:transparent;
border:1px solid var(--glass-border);
color:var(--muted);
padding:10px 18px;
border-radius:12px;
font-weight:700;
text-decoration:none;
}
.micro-note{
margin-top:12px;
font-size:13px;
opacity:0.8;
}
/* HERO RIGHT: preview card (calendar + next prayer) */
.preview-card{
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
border: 1px solid var(--glass-border);
padding:18px;
border-radius:var(--radius-lg);
box-shadow: var(--shadow);
}
.preview-head{
display:flex;
justify-content:space-between;
align-items:center;
gap:8px;
margin-bottom:12px;
}
.location{
font-weight:700;
color:white;
}
.next-prayer{
display:flex;
gap:12px;
align-items:center;
}
.next-prayer .time{
font-weight:800;
font-size:22px;
color:white;
}
.countdown{
font-size:13px;
opacity:0.9;
}
.mini-calendar{
margin-top:12px;
display:grid;
grid-template-columns: repeat(7, 1fr);
gap:6px;
}
.mini-calendar .day{
padding:8px 6px;
text-align:center;
border-radius:8px;
font-size:13px;
background:transparent;
color:var(--muted);
border:1px solid transparent;
}
.mini-calendar .holiday{
background: rgba(220,38,38,0.15);
color: #ffd7d7;
border:1px solid rgba(220,38,38,0.2);
}
.mini-calendar .today{
background: linear-gradient(90deg,#0ea5a4, #06b6d4);
color:white;
font-weight:700;
}
/* FEATURES */
.section{
padding:72px 0;
}
.section h2{
font-size:28px;
color:#fff;
margin-bottom:18px;
font-weight:800;
}
.lead{
color:var(--muted);
margin-bottom:28px;
font-size:15px;
max-width:760px;
}
.features-grid{
display:grid;
grid-template-columns: repeat(3, 1fr);
gap:20px;
}
.card{
background:var(--card);
border-radius:12px;
padding:20px;
border:1px solid var(--glass-border);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.card:hover{ transform: translateY(-8px); box-shadow: 0 20px 40px rgba(2,6,23,0.6) }
.card h3{ font-size:16px; margin-bottom:10px; color:white; font-weight:800 }
.card p{ font-size:14px; opacity:0.9; line-height:1.5 }
/* STATS / BADGES */
.badges{
display:flex;
gap:12px;
margin-top:18px;
flex-wrap:wrap;
}
.badge{
padding:10px 14px;
border-radius:999px;
background: rgba(255,255,255,0.03);
font-weight:700;
font-size:13px;
}
/* CTA BAR */
.cta-bar{
margin-top:30px;
display:flex;
gap:12px;
align-items:center;
justify-content:center;
flex-wrap:wrap;
padding:18px;
border-radius:12px;
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
border:1px solid var(--glass-border);
}
/* FOOTER */
footer{
margin-top:40px;
padding:40px 0 80px;
color:var(--muted);
text-align:center;
border-top:1px solid var(--glass-border);
background: linear-gradient(180deg, transparent, rgba(0,0,0,0.25));
}
footer .frow{
display:flex;
justify-content:space-between;
gap:20px;
align-items:center;
max-width:var(--max-w);
margin:0 auto;
padding:0 20px;
}
.contact{
text-align:right;
}
.contact a{ color:var(--muted); text-decoration:none; font-weight:700 }
.copyright{ margin-top:18px; font-size:13px; opacity:0.8 }
/* RESPONSIVE */
@media (max-width:1000px){
.hero{ grid-template-columns: 1fr 360px }
.features-grid{ grid-template-columns: repeat(2, 1fr) }
}
@media (max-width:768px){
.nav-links{ display:none }
.hero{ grid-template-columns: 1fr; padding:64px 0 32px; gap:18px }
.preview-card{ order: -1 }
.features-grid{ grid-template-columns: 1fr }
.frow{ flex-direction:column; text-align:center }
.contact{ text-align:center }
}
/* small helpers */
.muted { opacity:0.86; font-size:14px }
a.reset { color:inherit; text-decoration:none; }
`}</style>
</head>
<body>
<nav>
<div className="container">
<div className="nav-row">
<div className="brand">
<div className="logo" aria-hidden>
𝕵
</div>
<div>
<div className="brand-title">Jadwal Sholat & Imam</div>
<div style={{ fontSize: 12, opacity: 0.75 }}>
Semua jadwal, imam, & libur satu genggaman
</div>
</div>
</div>
<div className="nav-links" role="navigation" aria-label="Main">
<a href="#features">Fitur</a>
<a href="#calendar">Kalender</a>
<a href="#imams">Jadwal Imam</a>
<a href="#docs">Dokumentasi</a>
<a href={clientRoutes["/dashboard"]} className="cta">
Masjid Saya
</a>
</div>
</div>
</div>
</nav>
<main>
<section className="hero">
<div className="container hero-left">
<div className="kicker">PWA · Open Source · API</div>
<h1>
Semua jadwal shalat, imam, dan hari libur dalam satu
genggaman.
</h1>
<p className="hero-lead">
Waktu adhan otomatis berdasarkan koordinat Anda. Countdown
real-time menuju iqomah, kalender interaktif, dan daftar libur
nasional yang ter-update setiap tahun ringan, cepat, dan bisa
dipasang di layar masjid.
</p>
<div className="hero-ctas">
<a href={clientRoutes["/shalat"]} className="btn-primary">
Lihat Demo
</a>
<a href="#docs" className="btn-ghost">
Dokumentasi API
</a>
<a href={clientRoutes["/dashboard"]} className="btn-ghost">
Pasang di Masjid Saya
</a>
</div>
<div className="micro-note">
<strong>Tips:</strong> Izinkan akses lokasi untuk akurasi adhan.
Bisa di-install sebagai PWA untuk akses 1-tap.
</div>
<div style={{ marginTop: 28 }}>
<div className="badges" aria-hidden>
<div className="badge">Next.js + SWR</div>
<div className="badge">Skeleton & Offline</div>
<div className="badge">Auto-load Hari Libur ID</div>
<div className="badge">REST API & Webhooks</div>
</div>
</div>
</div>
<aside
className="container preview-card"
aria-label="Preview jadwal"
>
<div className="preview-head">
<div>
<div className="location">Masjid Al-Hikmah Makassar</div>
<div className="muted" style={{ fontSize: 13 }}>
Koordinat: 5.1477° S, 119.4327° E
</div>
</div>
<div className="next-prayer" aria-live="polite">
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 12, opacity: 0.85 }}>
Sholat berikutnya
</div>
<div className="time">Maghrib 18:03</div>
<div className="countdown">
Iqomah dalam <strong>15m 12s</strong>
</div>
</div>
</div>
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 13,
marginBottom: 8,
color: "var(--muted)",
}}
>
Waktu adhan hari ini
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2,1fr)",
gap: 8,
}}
>
<div className="card" style={{ padding: 12 }}>
<div style={{ fontSize: 13, opacity: 0.9 }}>Subuh</div>
<div style={{ fontWeight: 800, fontSize: 16 }}>04:31</div>
</div>
<div className="card" style={{ padding: 12 }}>
<div style={{ fontSize: 13, opacity: 0.9 }}>Dzuhur</div>
<div style={{ fontWeight: 800, fontSize: 16 }}>12:03</div>
</div>
<div className="card" style={{ padding: 12 }}>
<div style={{ fontSize: 13, opacity: 0.9 }}>Ashr</div>
<div style={{ fontWeight: 800, fontSize: 16 }}>15:24</div>
</div>
<div className="card" style={{ padding: 12 }}>
<div style={{ fontSize: 13, opacity: 0.9 }}>Isya</div>
<div style={{ fontWeight: 800, fontSize: 16 }}>19:10</div>
</div>
</div>
</div>
<div style={{ marginTop: 14 }}>
<div
style={{
fontSize: 13,
marginBottom: 8,
color: "var(--muted)",
}}
>
Mini kalender klik tanggal untuk lihat imam
</div>
<div
className="mini-calendar"
role="grid"
aria-label="Mini kalender"
>
{/* Static example days — highlight classes indicate holiday/today */}
<div className="day muted">Min</div>
<div className="day muted">Sen</div>
<div className="day muted">Sel</div>
<div className="day muted">Rab</div>
<div className="day muted">Kam</div>
<div className="day muted">Jum</div>
<div className="day muted">Sab</div>
<div className="day">1</div>
<div className="day">2</div>
<div className="day holiday">3</div>
<div className="day">4</div>
<div className="day">5</div>
<div className="day">6</div>
<div className="day today">7</div>
{/* more days... */}
</div>
</div>
</aside>
</section>
<section id="features" className="section container">
<h2>Mengapa pilih Jadwal Sholat & Imam?</h2>
<p className="lead">
Satu tampilan untuk semua informasi: adhan otomatis sesuai
koordinat, countdown iqomah real-time, kalender interaktif, dan
daftar libur nasional yang diambil otomatis tiap tahun.
</p>
<div className="features-grid" style={{ marginTop: 12 }}>
<div className="card" role="article" aria-labelledby="f1">
<h3 id="f1">Satu tampilan, semua informasi</h3>
<p>
Waktu adhan (fajr, dhuhr, asr, maghrib, isha) otomatis
menyesuaikan lokasi Anda. Icon dinamis membantu membaca
kondisi (matahari, senja, bulan).
</p>
</div>
<div className="card" role="article" aria-labelledby="f2">
<h3 id="f2">Kalender interaktif</h3>
<p>
Klik tanggal untuk melihat jadwal imam dan waktu adhan di hari
tersebut. Hari libur nasional berwarna merah; hari ini
di-highlight biru. Navigasi bulan & tahun cepat seperti swipe.
</p>
</div>
<div className="card" role="article" aria-labelledby="f3">
<h3 id="f3">Jadwal imam bulanan</h3>
<p>
Tabel ringkasan 30 hari menampilkan siapa imam & menit iqomah
setiap hari cocok untuk pengingat, laporan, atau tampilan
papan pengumuman di masjid.
</p>
</div>
<div className="card" role="article" aria-labelledby="f4">
<h3 id="f4">Daftar libur nasional otomatis</h3>
<p>
Auto-load libur Indonesia tiap tahun lengkap dengan tipe
(libur nasional / cuti bersama) dan keterangan tambahan untuk
perencanaan kegiatan masjid.
</p>
</div>
<div className="card" role="article" aria-labelledby="f5">
<h3 id="f5">Ringan & cepat</h3>
<p>
Menggunakan strategi cache dan incremental fetch (SWR /
stale-while-revalidate) hanya data berubah diambil ulang.
Skeleton screen & PWA untuk pengalaman stabil di jaringan
lambat.
</p>
</div>
<div className="card" role="article" aria-labelledby="f6">
<h3 id="f6">Open source & mudah dikustom</h3>
<p>
Kode tersedia di GitHub. Ganti koordinat, tema, atau aktifkan
push-notification dengan cepat. REST API internal siap dipakai
untuk mobile atau display LED.
</p>
</div>
</div>
<div className="cta-bar" style={{ marginTop: 26 }}>
<a href={clientRoutes["/shalat"]} className="btn-primary">
Coba Demo Sekarang
</a>
<a href="#docs" className="btn-ghost">
Buka Dokumentasi API
</a>
<a href={clientRoutes["/dashboard"]} className="btn-ghost">
Pasang di Masjid Saya
</a>
</div>
</section>
<section
id="imams"
className="section container"
style={{ paddingTop: 20 }}
>
<h2>Contoh Jadwal Imam Bulanan</h2>
<p className="lead">
Ringkasan 30 hari: lihat siapa imam setiap hari, menit iqomah, dan
catatan (cuti / acara khusus).
</p>
<div style={{ marginTop: 12, overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
minWidth: 720,
}}
>
<thead>
<tr
style={{
textAlign: "left",
borderBottom: "1px solid var(--glass-border)",
}}
>
<th style={{ padding: 12 }}>Tanggal</th>
<th style={{ padding: 12 }}>Imam</th>
<th style={{ padding: 12 }}>Iqomah (menit)</th>
<th style={{ padding: 12 }}>Catatan</th>
</tr>
</thead>
<tbody>
<tr
style={{ borderBottom: "1px solid rgba(255,255,255,0.03)" }}
>
<td style={{ padding: 12 }}>2025-12-07</td>
<td style={{ padding: 12 }}>Ust. Ahmad</td>
<td style={{ padding: 12 }}>10</td>
<td style={{ padding: 12 }}></td>
</tr>
<tr
style={{ borderBottom: "1px solid rgba(255,255,255,0.03)" }}
>
<td style={{ padding: 12 }}>2025-12-08</td>
<td style={{ padding: 12 }}>Ust. Budi</td>
<td style={{ padding: 12 }}>8</td>
<td style={{ padding: 12 }}>Libur Nasional</td>
</tr>
{/* more rows — replace with dynamic data in real app */}
</tbody>
</table>
</div>
</section>
<section
id="docs"
className="section container"
style={{ paddingTop: 8 }}
>
<h2>Cepat Mulai</h2>
<p className="lead">
1) Kunjungi{" "}
<a href="https://jadwalsholat.example.com" className="reset">
jadwalsholat.example.com
</a>
<br />
2) Izinkan akses lokasi waktu adhan otomatis.
<br />
3) Klik tanggal di kalender untuk melihat jadwal imam.
<br />
4) Tambahkan ke layar utama sebagai PWA untuk akses 1-tap.
</p>
<div
style={{
display: "flex",
gap: 12,
marginTop: 10,
flexWrap: "wrap",
}}
>
<a href={clientRoutes["/shalat"]} className="btn-primary">
Lihat Demo
</a>
<a href="#docs" className="btn-ghost">
Dokumentasi API
</a>
<a href={clientRoutes["/dashboard"]} className="btn-ghost">
Pasang di Masjid Saya
</a>
</div>
</section>
</main>
<footer>
<div className="frow">
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 800, fontSize: 16, color: "#fff" }}>
Jadwal Sholat & Imam
</div>
<div style={{ marginTop: 6, fontSize: 13, opacity: 0.8 }}>
Perangkat lunak open-source untuk manajemen jadwal masjid.
</div>
</div>
<div className="contact">
<div style={{ fontWeight: 700 }}>Hubungi kami</div>
<div style={{ marginTop: 6 }}>
<a href="mailto:hi@jadwalsholat.example.com">
hi@jadwalsholat.example.com
</a>
</div>
<div style={{ marginTop: 6 }}>
<a href="tel:+6281234567890">+62 812 3456 7890</a>
</div>
</div>
</div>
<div className="container copyright">
<div>PERCOBAAN GRATIS · TANPA IKLAN · DATA AMAN</div>
<div style={{ marginTop: 8 }}>
© {new Date().getFullYear()} Jadwal Sholat & Imam. All rights
reserved.
</div>
</div>
</footer>
</body>
</html>
);
}

19
src/clientRoutes.ts Normal file
View File

@@ -0,0 +1,19 @@
// AUTO-GENERATED
const clientRoutes = {
"/shalat": "/shalat",
"/shalat/shalat": "/shalat/shalat",
"/login": "/login",
"/": "/",
"/register": "/register",
"/dashboard": "/dashboard",
"/dashboard/shalat": "/dashboard/shalat",
"/dashboard/shalat/dashboard-shalat": "/dashboard/shalat/dashboard-shalat",
"/dashboard/config": "/dashboard/config",
"/dashboard/config/config": "/dashboard/config/config",
"/dashboard/apikey/apikey": "/dashboard/apikey/apikey",
"/dashboard/dashboard": "/dashboard/dashboard",
"/profile": "/profile",
"/profile/profile": "/profile/profile",
"/*": "/*"
} as const;
export default clientRoutes;

33
src/components/Logout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import { Button, Group } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useNavigate } from "react-router-dom";
export function Logout() {
const navigate = useNavigate();
return (
<Group justify="flex-end">
<Button
variant="light"
color="red"
size="xs"
onClick={async () => {
modals.openConfirmModal({
title: "Confirm Logout",
children: "Are you sure you want to logout?",
labels: { confirm: "Logout", cancel: "Cancel" },
confirmProps: { color: "red" },
onCancel: () => {},
onConfirm: async () => {
await apiFetch.auth.logout.delete();
navigate(clientRoutes["/login"]);
},
});
}}
>
Logout
</Button>
</Group>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
export default function ProtectedRoute() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null; // or loading spinner
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
return <Outlet />;
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import type { USER_ROLE } from "generated/prisma";
export default function RoleRoute({ role }: { role: USER_ROLE }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [userRole, setUserRole] = useState<USER_ROLE | null>(null);
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const { data, status } = await apiFetch.api.user.find.get();
setUserRole(data?.user?.role || null);
setIsAuthenticated(status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null; // or loading spinner
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
if (isAuthenticated && userRole !== role)
return <Navigate to={clientRoutes["/profile"]} replace />;
return <Outlet />;
}

26
src/frontend.tsx Normal file
View File

@@ -0,0 +1,26 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
if (import.meta.hot) {
// With hot module reloading, `import.meta.hot.data` is persisted.
const root = (import.meta.hot.data.root ??= createRoot(elem));
root.render(app);
} else {
// The hot module reloading API is not available in production.
createRoot(elem).render(app);
}

187
src/index.css Normal file
View 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;
}
}

13
src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Bun + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

137
src/index.tsx Normal file
View File

@@ -0,0 +1,137 @@
import Elysia, { t } from "elysia";
import Swagger from "@elysiajs/swagger";
import html from "./index.html";
import { apiAuth } from "./server/middlewares/apiAuth";
import Auth from "./server/routes/auth_route";
import ApiKeyRoute from "./server/routes/apikey_route";
import type { User } from "generated/prisma";
import { LandingPage } from "./Landing";
import { renderToReadableStream } from "react-dom/server";
import { cors } from "@elysiajs/cors";
import packageJson from "./../package.json";
import Configs from "./server/routes/configs_route";
import { prisma } from "./server/lib/prisma";
import JadwalSholat from "./server/routes/jadwal_sholat";
const PORT = process.env.PORT || 3000;
const Docs = new Elysia().use(
Swagger({
path: "/docs",
specPath: "/spec",
exclude: ["/docs", "/spec"],
documentation: {
info: {
title: packageJson.name,
version: packageJson.version,
description: "API documentation for " + packageJson.name,
contact: {
name: "Malik Kurosaki",
email: "kurosakiblackangel@gmail.com",
url: "https://github.com/malikkurosaki",
},
license: {
name: "MIT",
url:
"https://github.com/malikkurosaki/" +
packageJson.name +
"/blob/main/LICENSE",
},
},
servers: [
{
url: process.env.BASE_URL || "http://localhost:3000",
description: process.env.BASE_URL
? "Production server"
: "Local development server",
},
],
},
}),
);
const ApiUser = new Elysia({
prefix: "/user",
}).get(
"/find",
(ctx) => {
const { user } = ctx as any;
return {
user: user as User,
};
},
{
detail: {
description: "Get the current user information",
summary: "Retrieve authenticated user details",
tags: ["User"],
},
},
);
const Api = new Elysia({
prefix: "/api",
})
.use(Configs)
.use(apiAuth)
.use(ApiKeyRoute)
.use(ApiUser)
.use(JadwalSholat);
const app = new Elysia()
.use(cors())
.use(Api)
.use(Docs)
.use(Auth)
.get(
"/get-allow-register",
async () => {
const configs = await prisma.configs.findUnique({ where: { id: "1" } });
return { allowRegister: configs?.allowRegister };
},
{
detail: {
description: "Get allow register",
summary: "get allow register",
},
},
)
.get(
"/assets/:name",
(ctx) => {
try {
const file = Bun.file(`public/${encodeURIComponent(ctx.params.name)}`);
return new Response(file);
} catch (error) {
return new Response("File not found", { status: 404 });
}
},
{
detail: {
description: "Serve static asset files",
summary: "Get a static asset by name",
tags: ["Static Assets"],
},
},
)
.get(
"/",
async () => {
const stream = await renderToReadableStream(<LandingPage />);
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
},
{
detail: {
description: "Landing page for " + packageJson.name,
summary: "Get the main landing page",
tags: ["General"],
},
},
)
.get("/*", html)
.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
export type ServerApp = typeof app;

11
src/lib/apiFetch.ts Normal file
View File

@@ -0,0 +1,11 @@
import { treaty } from '@elysiajs/eden'
import type { ServerApp } from '..'
const URL = process.env.BUN_PUBLIC_BASE_URL
if (!URL) {
throw new Error('BUN_PUBLIC_BASE_URL is not defined')
}
const apiFetch = treaty<ServerApp>(URL)
export default apiFetch

1
src/logo.svg Normal file
View 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

31
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,31 @@
import clientRoutes from "@/clientRoutes";
import { Button, Card, Container, Group, Stack, Title } from "@mantine/core";
export default function Home() {
return (
<Container size={420} py={80}>
<Card shadow="sm" padding="xl" radius="md">
<Stack gap="md">
<Title order={2} ta="center">
Home
</Title>
<Group grow>
<Button size="sm" component="a" href={clientRoutes["/dashboard"]}>
Dashboard
</Button>
<Button
size="sm"
component="a"
href={clientRoutes["/login"]}
variant="light"
>
Login
</Button>
</Group>
</Stack>
</Card>
</Container>
);
}

100
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,100 @@
import clientRoutes from "@/clientRoutes";
import {
Button,
Card,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import apiFetch from "../lib/apiFetch";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const handleSubmit = async () => {
setLoading(true);
try {
const response = await apiFetch.auth.login.post({
email,
password,
});
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
window.location.href = "/dashboard";
return;
}
if (response.error) {
alert(JSON.stringify(response.error));
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null;
if (isAuthenticated)
return <Navigate to={clientRoutes["/dashboard"]} replace />;
return (
<Container size={420} py={80}>
<Card shadow="sm" radius="md" padding="xl">
<Stack gap="md">
<Title order={2} ta="center">
Login
</Title>
<TextInput
label="Email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<PasswordInput
label="Password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Group justify="flex-end" mt="sm">
<Button onClick={handleSubmit} loading={loading} fullWidth>
Login
</Button>
</Group>
<Text ta="center" size="sm">
Don't have an account? <a href="/register">Register</a>
</Text>
</Stack>
</Card>
</Container>
);
}

19
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Anchor, Container, Text } from "@mantine/core";
export default function NotFound() {
return (
<Container>
<Text size="xl" ta="center" mb="md">
404 Not Found
</Text>
<Text ta="center" mb="lg">
The page you are looking for does not exist.
</Text>
<Text ta="center">
<Anchor href="/" c="blue" underline="hover">
Go back home
</Anchor>
</Text>
</Container>
);
}

134
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,134 @@
import clientRoutes from "@/clientRoutes";
import {
Button,
Card,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import apiFetch from "../lib/apiFetch";
import useSWR from "swr";
import { useNavigate } from "react-router-dom";
export default function Register() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const navigate = useNavigate();
const { data, error, isLoading } = useSWR(
"/",
apiFetch["get-allow-register"].get,
);
const allowRegister = data?.data?.allowRegister ?? false;
const handleSubmit = async () => {
setLoading(true);
try {
const response = await apiFetch.auth.register.post({
name,
email,
password,
});
if (response.data?.success) {
window.location.href = clientRoutes["/login"];
return;
}
if (response.error) {
alert(JSON.stringify(response.error));
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
async function checkSession() {
try {
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isLoading) return <Text>Loading...</Text>;
if (error) return <Text>Error: {error.message}</Text>;
if (isAuthenticated === null) return null;
if (isAuthenticated)
return <Navigate to={clientRoutes["/dashboard"]} replace />;
if (!allowRegister)
return (
<Container size={"md"} w={"100%"}>
<Group justify="center">
<Stack>
<Text>Allow register is disabled</Text>
<Button onClick={() => navigate(clientRoutes["/login"])}>
Back to login
</Button>
</Stack>
</Group>
</Container>
);
return (
<Container size={420} py={80}>
<Card shadow="sm" radius="md" padding="xl">
<Stack gap="md">
<Title order={2} ta="center">
Register
</Title>
<TextInput
label="Name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<TextInput
label="Email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<PasswordInput
label="Password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Group justify="flex-end" mt="sm">
<Button onClick={handleSubmit} loading={loading} fullWidth>
Register
</Button>
</Group>
<Text ta="center" size="sm">
Already have an account? <a href="/login">Login</a>
</Text>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,232 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
Container,
Divider,
Group,
Loader,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { showNotification } from "@mantine/notifications";
import { useEffect, useState } from "react";
import useSwr from "swr";
export default function ApiKeyPage() {
return (
<Container size="md" w="100%" py="lg">
<Stack gap="lg">
<Title order={2}>API Key Management</Title>
<CreateApiKey />
<ListApiKey />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [expiredAt, setExpiredAt] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
try {
setLoading(true);
if (!name || !description) {
showNotification({
title: "Error",
message: "All fields are required",
color: "red",
});
return;
}
const res = await apiFetch.api.apikey.create.post({
name,
description,
expiredAt: expiredAt
? new Date(expiredAt).toISOString()
: new Date().toISOString(),
});
if (res.status === 200) {
setName("");
setDescription("");
setExpiredAt("");
showNotification({
title: "Success",
message: "API key created successfully",
color: "green",
});
}
setLoading(false);
} catch (error) {
showNotification({
title: "Error",
message: "Failed to create API key " + JSON.stringify(error),
color: "red",
});
setLoading(false);
} finally {
setLoading(false);
}
};
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>Create API Key</Title>
<TextInput
label="Name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<TextInput
label="Description"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Expired At"
placeholder="Expired At"
type="date"
value={expiredAt}
onChange={(e) => setExpiredAt(e.target.value)}
/>
<Group justify="flex-end" mt="sm">
<Button
variant="outline"
onClick={() => {
setName("");
setDescription("");
setExpiredAt("");
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} type="submit" loading={loading}>
Save
</Button>
</Group>
</Stack>
</Card>
);
}
function ListApiKey() {
const { data, error, isLoading, mutate } = useSwr(
"/",
() => apiFetch.api.apikey.list.get(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
},
);
const apiKeys = data?.data?.apiKeys || [];
useEffect(() => {
mutate();
}, []);
if (error) return <Text color="red">Error fetching API keys</Text>;
if (isLoading) return <Loader />;
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>API Key List</Title>
<Divider />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Expired At</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 160 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys.map((apiKey: any, index: number) => (
<Table.Tr key={index}>
<Table.Td>{apiKey.name}</Table.Td>
<Table.Td>{apiKey.description}</Table.Td>
<Table.Td>
{apiKey.expiredAt?.toISOString().split("T")[0]}
</Table.Td>
<Table.Td>
{apiKey.createdAt?.toISOString().split("T")[0]}
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button
variant="light"
size="xs"
onClick={() => {
modals.openConfirmModal({
title: "Delete API Key",
children: (
<Text>
Are you sure you want to delete this API key?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
onCancel: () => {},
onConfirm: async () => {
await apiFetch.api.apikey.delete.delete({
id: apiKey.id,
});
mutate();
},
});
}}
>
Delete
</Button>
<Button
variant="outline"
size="xs"
onClick={() => {
navigator.clipboard.writeText(apiKey.key);
showNotification({
title: "Copied",
message: "API key copied to clipboard",
color: "green",
});
}}
>
Copy
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function ConfigLayout() {
return <Outlet />;
}

View File

@@ -0,0 +1,60 @@
import apiFetch from "@/lib/apiFetch";
import { Container, Stack, Switch, Text } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { useState } from "react";
import useSWR from "swr";
export default function ConfigPage() {
const { data, error, isLoading } = useSWR(
"/",
apiFetch["get-allow-register"].get,
);
const [allowRegister, setAllowRegister] = useState(false);
useShallowEffect(() => {
if (data) {
setAllowRegister(data.data?.allowRegister ?? false);
}
}, [data]);
if (isLoading)
return (
<Container size="lg" w={"100%"}>
<Text>Loading...</Text>
</Container>
);
if (error)
return (
<Container size="lg" w={"100%"}>
<Text>Error: {error.message}</Text>
</Container>
);
async function updateAllowRegister({
allowRegister,
}: {
allowRegister: boolean;
}) {
const res = await apiFetch.api.configs["update-allow-register"].post({
allowRegister: allowRegister,
});
console.log(res.data);
setAllowRegister(res.data?.allowRegister ?? false);
}
return (
<Container size="lg" w={"100%"}>
<Stack>
<Text>Config Page</Text>
<Switch
label="Allow Register"
checked={allowRegister}
onChange={(e) => {
updateAllowRegister({ allowRegister: !allowRegister });
}}
/>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,203 @@
import { useEffect, useState } from "react";
import {
ActionIcon,
AppShell,
Avatar,
Card,
Divider,
Flex,
Group,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconChevronLeft,
IconChevronRight,
IconDashboard,
IconKey,
IconSettings,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import { useLocation, useNavigate } from "react-router-dom";
import {
default as clientRoute,
default as clientRoutes,
} from "@/clientRoutes";
import { Logout } from "@/components/Logout";
import RoleRoute from "@/components/RoleRoute";
import apiFetch from "@/lib/apiFetch";
/* ----------------------- Layout ----------------------- */
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: "nav_open",
defaultValue: true,
});
return (
<AppShell
padding="md"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened },
}}
>
{/* NAVBAR */}
<AppShell.Navbar p="sm">
{/* Collapse toggle */}
<AppShell.Section>
<Group justify="flex-end">
<Tooltip
label={opened ? "Collapse navigation" : "Expand navigation"}
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened((v) => !v)}
radius="xl"
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
{/* Navigation */}
<AppShell.Section grow component={ScrollArea} mt="sm">
<NavigationDashboard />
</AppShell.Section>
{/* User info */}
<AppShell.Section>
<HostView />
</AppShell.Section>
</AppShell.Navbar>
{/* MAIN CONTENT */}
<AppShell.Main>
<Stack>
<Paper withBorder radius="lg" p="md" shadow="sm">
<Flex align="center" gap="md">
{!opened && (
<Tooltip label="Open navigation menu" withArrow>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(true)}
radius="xl"
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Title order={3} fw={600}>
App Dashboard
</Title>
</Flex>
</Paper>
<RoleRoute role="ADMIN" />
</Stack>
</AppShell.Main>
</AppShell>
);
}
/* ----------------------- Host Info ----------------------- */
function HostView() {
const [host, setHost] = useState<User | null>(null);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
}
fetchHost();
}, []);
return (
<Card radius="md" withBorder shadow="xs" p="md">
{host ? (
<Stack gap="sm">
<Flex gap="md" align="center">
<Avatar size="lg" radius="xl" color="blue">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600} size="sm">
{host.name}
</Text>
<Text size="xs" c="dimmed">
{host.email}
</Text>
</Stack>
</Flex>
<Divider />
<Logout />
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
No host information available
</Text>
)}
</Card>
);
}
/* ----------------------- Navigation ----------------------- */
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
return (
<Stack gap="xs">
<NavLink
active={isActive("/dashboard/dashboard")}
leftSection={<IconDashboard size={18} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes["/dashboard/dashboard"])}
/>
<NavLink
active={isActive("/dashboard/apikey/apikey")}
leftSection={<IconKey size={18} />}
label="API Keys"
description="Manage your API credentials"
onClick={() => navigate(clientRoutes["/dashboard/apikey/apikey"])}
/>
<NavLink
active={isActive("/dashboard/config/config")}
leftSection={<IconSettings size={18} />}
label="Config"
description="Manage your app config"
onClick={() => navigate(clientRoutes["/dashboard/config/config"])}
/>
<NavLink
active={isActive("/dashboard/shalat/dashboard-shalat")}
leftSection={<IconSettings size={18} />}
label="Jadwal Imam"
description="Manage your app config"
onClick={() =>
navigate(clientRoutes["/dashboard/shalat/dashboard-shalat"])
}
/>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import {
Button,
Card,
Container,
Divider,
Group,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
export default function Dashboard() {
return (
<Container>
<Stack gap="lg">
{/* -------- STATS SECTION -------- */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Total Users
</Text>
<Title order={3}>1,234</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Active Sessions
</Text>
<Title order={3}>87</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
API Calls today
</Text>
<Title order={3}>12,490</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Errors
</Text>
<Title order={3}>5</Title>
</Card>
</SimpleGrid>
{/* -------- QUICK ACTIONS -------- */}
<Card shadow="sm" radius="md" padding="lg">
<Group justify="space-between" mb="sm">
<Title order={4}>Quick Actions</Title>
</Group>
<Group>
<Button>Add API Key</Button>
<Button variant="outline">Manage Users</Button>
<Button variant="light">View Logs</Button>
</Group>
</Card>
{/* -------- ACTIVITY TABLE -------- */}
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>Recent Activity</Title>
<Divider />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Action</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>John Doe</Table.Td>
<Table.Td>Generated new API key</Table.Td>
<Table.Td>2025-01-21</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="green">
Success
</Button>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Ana Smith</Table.Td>
<Table.Td>Deleted session</Table.Td>
<Table.Td>2025-01-20</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="blue">
Info
</Button>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Michael</Table.Td>
<Table.Td>Failed login attempt</Table.Td>
<Table.Td>2025-01-19</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="red">
Error
</Button>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function JadwalShalat() {
return <Outlet />;
}

View File

@@ -0,0 +1,161 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
Container,
Flex,
Group,
Loader,
Radio,
Stack,
Switch,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import type { Configs, User } from "generated/prisma";
import { useState } from "react";
import useSwr from "swr";
export default function JadwalShalat() {
return (
<Container size="md" w={"100%"}>
<Stack>
<ListUser />
{/* <ConfigView /> */}
<ConfigUpdate />
</Stack>
</Container>
);
}
function ListUser() {
const { data, error, isLoading, mutate } = useSwr(
"/",
apiFetch.api["jadwal-sholat"]["user-list"].get,
);
const [listUser, setListUser] = useState<User[]>([]);
useShallowEffect(() => {
setListUser(data?.data?.data ?? []);
}, [data]);
if (isLoading) return <Loader />;
if (error) return <Text>{error.message}</Text>;
return (
<Card>
<Stack>
<Title order={4}>List User</Title>
{listUser.map((user) => (
<Stack key={user.id}>
<Flex>
<Text w={200}>{user.name}</Text>
<Switch
defaultChecked={user.active}
onChange={async (e) => {
const { data } = await apiFetch.api["jadwal-sholat"][
"user-active"
].put({ id: user.id, active: e.target.checked });
mutate();
}}
/>
</Flex>
</Stack>
))}
</Stack>
</Card>
);
}
function ConfigView() {
const { data, error, isLoading, mutate } = useSwr(
"/apa",
apiFetch.api["jadwal-sholat"]["config"].get,
);
const [config, setConfig] = useState<any>(null);
useShallowEffect(() => {
setConfig(data?.data?.data ?? null);
// console.log(data?.data?.data);
}, [data]);
if (isLoading) return <Loader />;
if (error) return <Text>{error.message}</Text>;
return (
<Card>
<Stack>
<Title order={4}>Config</Title>
<Flex>
<Text w={200}>Imam Key</Text>
<Text>{config?.imamKey}</Text>
</Flex>
<Flex>
<Text w={200}>Ikomah Key</Text>
<Text>{config?.ikomahKey}</Text>
</Flex>
</Stack>
</Card>
);
}
function ConfigUpdate() {
const { data, error, isLoading, mutate } = useSwr(
"/apa",
apiFetch.api["jadwal-sholat"]["config"].get,
);
const [config, setConfig] = useState<Partial<Configs> | null>(null);
useShallowEffect(() => {
setConfig(data?.data?.data ?? null);
}, [data]);
async function handleUpdate() {
if (!config?.ikomahKey || !config?.imamKey) return notifications.show({
title: "Error",
message: "Config updated failed",
color: "red",
});
const { data , status} = await apiFetch.api["jadwal-sholat"]["config"].put({
id: "1",
ikomahKey: config.ikomahKey,
imamKey: config.imamKey,
});
if(status === 200 ) {
notifications.show({
title: "Success",
message: "Config updated successfully",
color: "green",
});
}else{
notifications.show({
title: "Error",
message: "Config updated failed",
color: "red",
});
}
}
if (isLoading) return <Loader />;
if (error) return <Text>{error.message}</Text>;
return (
<Card>
<Stack>
<Title order={4}>Config</Title>
<Flex>
<Text w={200}>Imam Key</Text>
<TextInput defaultValue={config?.imamKey} onChange={(e) => setConfig({ ...config, imamKey: e.target.value })} />
</Flex>
<Flex>
<Text w={200}>Ikomah Key</Text>
<TextInput defaultValue={config?.ikomahKey} onChange={(e) => setConfig({ ...config, ikomahKey: e.target.value })} />
</Flex>
<Group justify="end">
<Button onClick={handleUpdate}>Update</Button>
</Group>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,12 @@
import { Logout } from "@/components/Logout";
import ProtectedRoute from "@/components/ProtectedRoute";
import { Stack } from "@mantine/core";
export default function ProfileLayout() {
return (
<Stack>
<Logout />
<ProtectedRoute />
</Stack>
);
}

View File

@@ -0,0 +1,11 @@
import { Container, Stack, Title } from "@mantine/core";
export default function ProfilePage() {
return (
<Container size={"md"} w={"100%"}>
<Stack>
<Title>Profile</Title>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function AdhanLayout() {
return <Outlet />;
}

View File

@@ -0,0 +1,766 @@
import apiFetch from "@/lib/apiFetch";
import {
Badge,
Box,
Card,
Container,
Flex,
Group,
Loader,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
UnstyledButton
} from "@mantine/core";
import { DatePicker, DatePickerInput } from "@mantine/dates";
import dayjs from "dayjs";
import "dayjs/locale/id";
import duration from "dayjs/plugin/duration";
import { useEffect, useMemo, useState } from "react";
import { DateScrollPicker } from 'react-date-wheel-picker'
import {
IconCalendar,
IconCalendarEvent,
IconCalendarStar,
IconClock,
IconGift,
IconLayoutGrid,
IconMoon,
IconSun,
IconSunHigh,
IconSunrise,
IconSunset,
IconUser,
} from "@tabler/icons-react";
import DateHolidays, { type HolidaysTypes } from "date-holidays";
import useSwr from "swr";
dayjs.locale("id");
dayjs.extend(duration);
export function formatCountdown(dt: dayjs.Dayjs | null) {
if (!dt) return "-";
const now = dayjs();
const diff = dt.diff(now);
if (diff <= 0) return "Sudah lewat";
const dur = dayjs.duration(diff);
return `${dur.hours()}j ${dur.minutes()}m`;
}
export default function AdhanPage() {
const [date, setDate] = useState<Date | null>(new Date());
const [monthly, setMonthly] = useState<any>(null);
const [daily, setDaily] = useState<any>(null);
const [adhan, setAdhan] = useState<any>(null);
const [loading, setLoading] = useState(true);
// tahun & bulan yang dipakai
const year = dayjs(date).year();
const month = dayjs(date).month() + 1;
const selectedDateStr = dayjs(date).format("YYYY-MM-DD");
// ambil data dari backend
const fetchAll = async () => {
setLoading(true);
const bulan = await apiFetch.api["jadwal-sholat"].bulanan.get({
query: {
day: dayjs(date).date(),
month,
year,
},
});
const hari = await apiFetch.api["jadwal-sholat"].hari.get({
query: {
date: selectedDateStr,
holidays: [],
},
});
const ad = await apiFetch.api["jadwal-sholat"].adhan.get({
query: {
date: selectedDateStr,
latitude: -8.65,
longitude: 115.2167,
},
});
setMonthly(bulan.data);
setDaily(hari.data);
setAdhan(ad.data);
setLoading(false);
};
useEffect(() => {
fetchAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [date]);
// generate holidays tahun ini (DateHolidays)
const holidays = useMemo(() => {
const hd = new DateHolidays("ID");
return hd.getHolidays(year) || [];
}, [year]);
// helper: apakah tanggal tertentu (YYYY-MM-DD) merupakan hari libur?
const isHoliday = (d: string) => {
return holidays.some(
(h) =>
dayjs(h.date).format("YYYY-MM-DD") === dayjs(d).format("YYYY-MM-DD"),
);
};
// ========== Calendar grid (month view) ==========
// buat array days untuk bulan ini (1..n) + offset untuk memulai hari pada weekday yg benar
const monthGrid = useMemo(() => {
const first = dayjs(`${year}-${String(month).padStart(2, "0")}-01`);
const daysInMonth = first.daysInMonth();
const startWeekday = first.day(); // 0 = Sunday ... 6 = Saturday
const cells: Array<{ date: dayjs.Dayjs | null }> = [];
// fill leading blanks
for (let i = 0; i < startWeekday; i++) {
cells.push({ date: null });
}
// fill actual days
for (let d = 1; d <= daysInMonth; d++) {
cells.push({
date: dayjs(
`${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
),
});
}
// fill trailing blanks to complete week rows (7 cols)
while (cells.length % 7 !== 0) {
cells.push({ date: null });
}
return { cells, daysInMonth, startWeekday };
}, [year, month]);
// ========== Prayer cards (per waktu) ==========
const prayerList = useMemo(() => {
// adhan.adhan is expected: { fajr, sunrise, dhuhr, asr, maghrib, isha }
if (!adhan || !adhan.adhan) return [];
const mapIcon: Record<string, any> = {
fajr: IconSunrise,
sunrise: IconSun,
dhuhr: IconSunHigh ?? IconSun,
asr: IconSun,
maghrib: IconSunset ?? IconSun,
isha: IconMoon,
};
return Object.entries(adhan.adhan).map(([key, timeStr]) => {
// build full datetime for the selected date
const dt = dayjs(`${selectedDateStr} ${timeStr}`, "YYYY-MM-DD HH:mm");
const now = dayjs();
const diffMs = dt.diff(now);
const isPast = diffMs <= 0;
const until = isPast
? null
: dayjs.duration
? dayjs.duration(diffMs)
: null; // dayjs duration optional
return {
name: key,
time: timeStr,
dt,
isPast,
until,
Icon: mapIcon[key] ?? IconClock,
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adhan, selectedDateStr]);
// small util for countdown string
const formatCountdown = (dt: dayjs.Dayjs) => {
const now = dayjs();
const diff = dt.diff(now);
if (diff <= 0) return "Sudah lewat";
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `dalam ${hours}j ${mins}m`;
return `dalam ${mins}m`;
};
// safety guards if data missing
if (!monthly || !daily || !adhan) {
return (
<Container size="md" w="100%">
<Stack gap="xl" py="md">
<Skeleton height={20} radius="sm" />
<Skeleton height={200} radius="md" />
<Skeleton height={300} radius="md" />
</Stack>
</Container>
);
}
return (
<Container size="md" w="100%" px="sm">
<Stack gap="xl" py="md">
<Stack justify="apart" align="center">
<Stack justify="center" align="center">
<IconCalendar color="cyan" size={"6rem"} stroke={1.3} />
<Title order={2} fw={700}>
Jadwal Sholat & Imam
</Title>
<Text size="xs" c="dimmed">
{dayjs(date).locale("id").format("dddd, DD MMMM YYYY")}
</Text>
</Stack>
</Stack>
{JadwalHariIni(prayerList, formatCountdown)}
<SimpleGrid cols={2}>
{JadwalImamHariIni(date, daily, adhan)}
<Card
padding="lg"
radius="md"
bg={"linear-gradient(135deg, #614c34ff, #596c2fff)"}
>
<Stack gap="xs">
<Group gap={6}>
<IconCalendarEvent size={20} />
<Text fw={600}>Pilih Tanggal</Text>
</Group>
<DatePicker
locale="id"
value={date}
date={date || undefined}
renderDay={(d) => <Text c={ dayjs(d).date() === dayjs(date).date() ? "green" : isHoliday(dayjs(d).format("YYYY-MM-DD")) ? "red" : ""}>{dayjs(d).format("DD")}</Text>}
defaultDate={date || undefined}
onChange={setDate as any}
/>
{/* <DatePickerInput
locale="id"
value={date}
onChange={setDate as any}
placeholder="Pilih tanggal"
radius="md"
/> */}
</Stack>
</Card>
</SimpleGrid>
{CalendarTable(date, monthGrid, isHoliday, monthly, setDate, holidays)}
{RingkasanBulalan(monthly, year, month, isHoliday)}
{FullYearHoliday(year, holidays)}
</Stack>
</Container>
);
}
function JadwalImamHariIni(date: Date | null, daily: any, adhan: any) {
return (
<Card padding="lg" radius="md" bg={"dark.9"}>
<Stack gap="sm">
<Group>
<IconUser size={20} />
<Title order={5} fw={700}>
Jadwal Imam Hari Ini
</Title>
</Group>
<Text size="sm" c="dimmed">
{dayjs(date).format("dddd, DD MMMM YYYY")}
</Text>
<Group>
<Badge
leftSection={<IconUser size={14} />}
color="blue"
variant="light"
>
{daily.data.imam || "-"}
</Badge>
<Badge
leftSection={<IconClock size={14} />}
color="grape"
variant="light"
>
{daily.data.ikomah || "-"}
</Badge>
</Group>
<div style={{ height: 8 }} />
<Title order={6}>Waktu Adhan</Title>
<Stack gap={6}>
{Object.entries(adhan.adhan).map(([k, v]) => (
<Group key={k} justify="apart">
<Text w={100} style={{ textTransform: "capitalize" }}>
{k}
</Text>
<Text fw={700}>{v as string}</Text>
</Group>
))}
</Stack>
</Stack>
</Card>
);
}
function CalendarTable(
date: Date | null,
monthGrid: {
cells: { date: dayjs.Dayjs | null }[];
daysInMonth: number;
startWeekday: 0 | 1 | 2 | 3 | 4 | 5 | 6;
},
isHoliday: (d: string) => boolean,
monthly: any,
setDate: any,
holidays: HolidaysTypes.Holiday[],
) {
return (
<Card
bg={"dark.9"}
padding="md"
radius="lg"
shadow="sm"
style={{
overflowX: "scroll",
}}
>
<Stack miw={700} gap="sm">
<Group justify="apart">
<Group>
<IconLayoutGrid size={20} />
<Title order={4} fw={700}>
Kalender {dayjs(date).format("MMMM YYYY")}
</Title>
</Group>
<Text size="sm" c="dimmed">
Klik tanggal untuk lihat detail
</Text>
</Group>
{/* weekday headers */}
<SimpleGrid cols={7}>
{["Min", "Sen", "Sel", "Rab", "Kam", "Jum", "Sab"].map((w) => (
<div key={w}>{w}</div>
))}
</SimpleGrid>
{/* month grid */}
<SimpleGrid cols={7}>
{monthGrid.cells.map((cell, idx) => {
if (!cell.date) {
return <div key={idx} style={{ minHeight: 80 }} />;
}
const d = cell.date;
const dayNum = d.date();
const iso = d.format("YYYY-MM-DD");
const today = d.isSame(dayjs(), "day");
const holiday = isHoliday(iso);
const hasImam = Boolean(
monthly.data.imam && monthly.data.imam[String(dayNum)],
);
return (
<UnstyledButton
bg={"dark"}
key={idx}
style={{
textAlign: "left",
padding: 10,
borderRadius: 12,
minHeight: 80,
background: today ? "rgba(5, 51, 104, 0.39)" : undefined,
border: holiday ? "1px solid rgba(130, 61, 6, 1)" : undefined,
boxShadow: today
? "0 6px 18px rgba(6,120,250,0.08)"
: undefined,
}}
onClick={() => {
setDate(d.toDate());
}}
>
<Stack
gap={0}
h={"100%"}
justify="space-around"
align="stretch"
>
<Group justify="end">
{
<Badge
size="xs"
bg={hasImam ? "green.4" : "gray"}
variant="light"
/>
}
</Group>
<Box>
<Text
size="2rem"
style={{
width: 34,
height: 34,
borderRadius: 8,
display: "grid",
placeItems: "center",
fontWeight: 700,
background: holiday
? "rgba(220,38,38,0.06)"
: "transparent",
color: holiday
? "rgb(220,38,38)"
: today
? "rgb(6,120,250)"
: undefined,
}}
>
{dayNum}
</Text>
<div>
<div
style={{
fontSize: 12,
fontWeight: 600,
textTransform: "capitalize",
}}
>
{d.format("dddd")}
</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>
{holiday
? holidays.find(
(h) => dayjs(h.date).format("YYYY-MM-DD") === iso,
)?.name
: ""}
</div>
</div>
</Box>
</Stack>
{/* small footer area */}
<div
style={{
marginTop: 8,
display: "flex",
gap: 8,
alignItems: "center",
}}
></div>
</UnstyledButton>
);
})}
</SimpleGrid>
</Stack>
</Card>
);
}
function RingkasanBulalan(
monthly: any,
year: number,
month: number,
isHoliday: (d: string) => boolean,
) {
return (
<Card padding="md" radius="md" mt="md" bg={"dark.9"}>
<Stack gap="sm">
<Group justify="apart">
<Group>
<IconCalendar size={18} />
<Text fw={700}>Ringkasan Bulanan</Text>
</Group>
</Group>
<UserList />
<SimpleGrid
cols={{
base: 2,
md: 3,
}}
>
{Object.keys(monthly.data.imam).map((d) => {
const tglNum = Number(d);
const iso = dayjs(
`${year}-${String(month).padStart(2, "0")}-${String(tglNum).padStart(2, "0")}`,
).format("YYYY-MM-DD");
const holiday = isHoliday(iso);
const isToday = dayjs(iso).isSame(dayjs(), "day");
return (
<Paper
radius="xl"
key={d}
p={"md"}
c={holiday ? "red" : isToday ? "blue" : "white"}
>
<Stack>
<Group>
<Text size="2rem" fw={isToday ? 700 : 500}>
{dayjs(iso).format("ddd, DD")}
</Text>
</Group>
<Stack gap={0}>
<Group>
<IconUser size={16} />
<Text fw={700}>{monthly.data.imam[d]}</Text>
</Group>
<Group>
<IconClock size={16} />
<Text fw={700}>{monthly.data.ikomah[d]}</Text>
</Group>
</Stack>
</Stack>
</Paper>
);
})}
</SimpleGrid>
{/* <Paper radius="sm" >
<Table verticalSpacing="sm" >
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 120 }}>Tanggal</Table.Th>
<Table.Th>Imam</Table.Th>
<Table.Th>Ikomah</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.keys(monthly.data.imam).map((d) => {
const tglNum = Number(d);
const iso = dayjs(
`${year}-${String(month).padStart(2, "0")}-${String(tglNum).padStart(2, "0")}`,
).format("YYYY-MM-DD");
const holiday = isHoliday(iso);
const isToday = dayjs(iso).isSame(dayjs(), "day");
return (
<Table.Tr
key={d}
c={holiday ? "red.9" : isToday ? "green.9" : ""}
>
<Table.Td>
<Text fw={isToday ? 700 : 500}>
{dayjs(iso).format("ddd, DD MMM")}
</Text>
</Table.Td>
<Table.Td>
{monthly.data.imam[d] ? (
<Badge
leftSection={<IconUser size={12} />}
variant="light"
color="blue"
>
{monthly.data.imam[d]}
</Badge>
) : (
<Text c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td>
{monthly.data.ikomah[d] ? (
<Badge
leftSection={<IconClock size={12} />}
variant="light"
color="grape"
>
{monthly.data.ikomah[d]}
</Badge>
) : (
<Text c="dimmed">-</Text>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper> */}
</Stack>
</Card>
);
}
function FullYearHoliday(year: number, holidays: HolidaysTypes.Holiday[]) {
return (
<Card padding="xl" radius="lg" shadow="md" bg={"dark.9"}>
<Stack gap="md">
<Group gap={6}>
<IconGift size={24} />
<Title order={4} fw={700}>
Hari Libur Nasional Tahun {year}
</Title>
</Group>
{holidays.length > 0 && (
<Stack>
<SimpleGrid
cols={{
base: 2,
md: 3,
}}
>
{holidays.map((h, idx) => {
const tgl = dayjs(h.date).format("DD MMMM YYYY");
return (
<Flex key={idx} gap={"md"} align="center">
<IconCalendarStar size={16} />
<Text>{tgl}</Text>
</Flex>
);
})}
</SimpleGrid>
</Stack>
)}
{/* <Paper radius="lg" >
<Table
stickyHeader
verticalSpacing="md"
highlightOnHover
w="100%"
>
<Table.Thead
style={{
position: "sticky",
top: 0,
background: "white",
zIndex: 2,
}}
>
<Table.Tr>
<Table.Th style={{ width: 240 }}>Tanggal</Table.Th>
<Table.Th>Nama Libur</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{holidays.map((h, idx) => {
const tgl = dayjs(h.date).format("dddd, DD MMMM YYYY");
return (
<Table.Tr key={idx}>
<Table.Td>
<Group>
<IconCalendarStar size={16} />
<Text>{tgl}</Text>
</Group>
</Table.Td>
<Table.Td style={{ fontWeight: 700 }}>{h.name}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper> */}
</Stack>
</Card>
);
}
function JadwalHariIni(
prayerList: {
name: string;
time: unknown;
dt: dayjs.Dayjs;
isPast: boolean;
until: duration.Duration | null;
Icon: any;
}[],
formatCountdown: (dt: dayjs.Dayjs) => string,
) {
return (
<Stack gap="sm">
<Title order={4}>Jadwal Shalat Hari Ini</Title>
<SimpleGrid
cols={{
base: 2,
md: 4,
}}
>
{prayerList.map((p) => {
const Icon = p.Icon;
return (
<Card key={p.name} radius="lg" bg={"dark.9"}>
<Stack
gap="xs"
align="stretch"
justify="space-between"
h={"100%"}
>
<Group justify="apart" align="center">
<Group>
<Icon size={22} />
<Text fw={700} style={{ textTransform: "capitalize" }}>
{p.name}
</Text>
</Group>
{/* <Text size="sm" c={p.isPast ? "dimmed" : undefined}>
{p.isPast ? "Sudah lewat" : (formatCountdown(p.dt) ?? "-")}
</Text> */}
</Group>
<Group justify="apart" align="center">
<Text size="sm" c={p.isPast ? "dimmed" : undefined}>
{p.isPast ? "Sudah lewat" : formatCountdown(p.dt)}
</Text>
<Badge
color={p.isPast ? "gray" : "blue"}
variant="light"
radius="sm"
>
{p.isPast ? "Lewat" : "Akan datang"}
</Badge>
</Group>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}
function UserList() {
const { data, error, isLoading } = useSwr(
"/",
apiFetch.api["jadwal-sholat"]["user-list"].get,
);
if (isLoading) return <Loader />;
if (error) return <Text c="red">{error.message}</Text>;
return (
<SimpleGrid
spacing={"sm"}
cols={{
base: 4,
sm: 6,
}}
>
{data?.data?.data?.map((u) => {
if (u.active === false) return null;
return (
<Card key={u.id} radius={"lg"} bg={"dark"}>
<Stack align="center">
<IconUser size={"3rem"} color="green" />
<Text>{u.name}</Text>
</Stack>
</Card>
);
})}
</SimpleGrid>
);
}

8
src/react.svg Normal file
View 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

11
src/routeTypes.ts Normal file
View File

@@ -0,0 +1,11 @@
export type AppRoute = "/shalat" | "/shalat/shalat" | "/login" | "/" | "/register" | "/dashboard" | "/dashboard/shalat" | "/dashboard/shalat/dashboard-shalat" | "/dashboard/config" | "/dashboard/config/config" | "/dashboard/apikey/apikey" | "/dashboard/dashboard" | "/profile" | "/profile/profile";
export function route(path: AppRoute, params?: Record<string,string|number>) {
if (!params) return path;
let final = path;
for (const k of Object.keys(params)) {
final = final.replace(":" + k, params[k] + "") as AppRoute;
}
return final;
}

View File

@@ -0,0 +1,228 @@
import DateHolidays from 'date-holidays'
const hd = new DateHolidays("ID")
/* ----------------------------------------------------------
Types
----------------------------------------------------------- */
export type PersonName = string;
export interface ScheduleMap {
[day: number]: PersonName | null;
}
export interface GetImamIkomahParams {
names: PersonName[];
year: number;
month: number;
keyImam: string;
keyIkomah: string;
liburTetap?: string[]; // contoh: ["sabtu", "minggu"]
liburNasional?: string[]; // contoh: ["2025-05-17"]
}
export interface GetForDateParams {
names: PersonName[];
date: string | Date;
keyImam: string;
keyIkomah: string;
liburTetap?: string[];
liburNasional?: string[];
}
/* ----------------------------------------------------------
Deterministic Seeded PRNG (Mulberry32)
----------------------------------------------------------- */
export function seededPRNG(seed: string): () => number {
let s = 0;
for (let i = 0; i < seed.length; i++) {
s = (s * 31 + seed.charCodeAt(i)) >>> 0;
}
return function () {
s += 0x6D2B79F5;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ----------------------------------------------------------
Seeded FisherYates Shuffle
----------------------------------------------------------- */
export function deterministicShuffle<T>(array: T[], seed: string): T[] {
const prng = seededPRNG(seed);
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(prng() * (i + 1));
[arr[i] as T, arr[j] as T] = [arr[j] as T, arr[i] as T];
}
return arr;
}
/* ----------------------------------------------------------
Utility: cek apakah hari adalah libur tetap (berdasarkan nama hari)
----------------------------------------------------------- */
function isFixedHoliday(
year: number,
month: number,
day: number,
liburTetap: string[]
): boolean {
const dayIndex = new Date(year, month - 1, day).getDay(); // 0=Min,6=Sab
const map: Record<number, string> = {
0: "minggu",
1: "senin",
2: "selasa",
3: "rabu",
4: "kamis",
5: "jumat",
6: "sabtu",
};
const nama = map[dayIndex];
return liburTetap.includes(nama as string);
}
/* ----------------------------------------------------------
Utility: cek libur nasional
----------------------------------------------------------- */
function isNationalHoliday(
year: number,
month: number,
day: number,
liburNasional?: string[]
): boolean {
if (!liburNasional) return false;
const d = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(
2,
"0"
)}`;
return liburNasional.includes(d);
}
/* ----------------------------------------------------------
Generate imam + ikomah schedule with holidays
----------------------------------------------------------- */
export function getImamIkomahSchedule({
names,
year,
month,
keyImam,
keyIkomah,
liburTetap = ["sabtu", "minggu"],
liburNasional = [],
}: GetImamIkomahParams) {
const imamList = deterministicShuffle(names, `${year}-${month}-${keyImam}`);
const ikomahList = deterministicShuffle(names, `${year}-${month}-${keyIkomah}`);
const daysInMonth = new Date(year, month, 0).getDate();
const imamSchedule: ScheduleMap = {};
const ikomahSchedule: ScheduleMap = {};
for (let day = 1; day <= daysInMonth; day++) {
const isFixed = isFixedHoliday(year, month, day, liburTetap);
const isNational = isNationalHoliday(year, month, day, liburNasional);
if (isFixed || isNational) {
imamSchedule[day] = null;
ikomahSchedule[day] = null;
continue;
}
const imam = imamList[(day - 1) % imamList.length];
let ikomahIdx = (day - 1) % ikomahList.length;
let ikomah = ikomahList[ikomahIdx];
if (ikomah === imam) {
ikomahIdx = (ikomahIdx + 1) % ikomahList.length;
ikomah = ikomahList[ikomahIdx];
}
imamSchedule[day] = imam as string;
ikomahSchedule[day] = ikomah as string;
}
return {
imam: imamSchedule,
ikomah: ikomahSchedule,
};
}
/* ----------------------------------------------------------
Get imam & ikomah for a specific date
----------------------------------------------------------- */
export function getForDate({
names,
date,
keyImam,
keyIkomah,
liburTetap = ["sabtu", "minggu"],
liburNasional = [],
}: GetForDateParams) {
const d = typeof date === "string" ? new Date(date) : date;
const year = d.getFullYear();
const month = d.getMonth() + 1;
const day = d.getDate();
const isFixed = isFixedHoliday(year, month, day, liburTetap);
const isNational = isNationalHoliday(year, month, day, liburNasional);
if (isFixed || isNational) {
return { imam: null, ikomah: null };
}
const full = getImamIkomahSchedule({
names,
year,
month,
keyImam,
keyIkomah,
liburTetap,
liburNasional,
});
return {
imam: full.imam[day],
ikomah: full.ikomah[day],
};
}
if (import.meta.main) {
const holiday = hd.getHolidays("2025")
const names = ["jun", "malik", "bagas", "nico", "keano"];
const liburNasional = [...holiday.map((h) => h.date)];
const liburTetap = ["sabtu", "minggu"]; // bisa ditambah seperti: ["jumat"]
const jadwal = getImamIkomahSchedule({
names,
year: 2025,
month: 8,
keyImam: "sdrtfsdrty",
keyIkomah: "dfdfdfdfdf",
liburTetap,
liburNasional,
});
console.log(jadwal);
console.log(
getForDate({
names,
date: "2025-06-16",
keyImam: "sdrtfsdrta",
keyIkomah: "dfdfdfdfdf",
liburTetap,
liburNasional,
})
);
}

11
src/server/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from 'generated/prisma'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

View File

@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia from 'elysia'
import { prisma } from '../lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
export function apiAuth(app: Elysia) {
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
return app
.use(
jwt({
name: 'jwt',
secret,
})
)
.derive(async ({ cookie, headers, jwt, request }) => {
let token: string | undefined
// 🔸 Ambil token dari Cookie
if (cookie?.token?.value) token = cookie.token.value as string
// 🔸 Ambil token dari Header (case-insensitive)
const possibleHeaders = [
'authorization',
'Authorization',
'x-token',
'X-Token',
]
for (const key of possibleHeaders) {
const value = headers[key]
if (typeof value === 'string') {
token = value.startsWith('Bearer ') ? value.slice(7) : value
break
}
}
// 🔸 Tidak ada token
if (!token) {
console.warn(`[AUTH] No token found for ${request.method} ${request.url}`)
return { user: null }
}
// 🔸 Verifikasi token
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (!decoded?.sub) {
console.warn('[AUTH] Token missing sub field:', decoded)
return { user: null }
}
const user = await prisma.user.findUnique({
where: { id: decoded.sub as string },
})
if (!user) {
console.warn('[AUTH] User not found for sub:', decoded.sub)
return { user: null }
}
return { user }
} catch (err) {
console.warn('[AUTH] Invalid JWT token:', err)
return { user: null }
}
})
.onBeforeHandle(({ user, set, request }) => {
if (!user) {
console.warn(
`[AUTH] Unauthorized access: ${request.method} ${request.url}`
)
set.status = 401
return { error: 'Unauthorized' }
}
})
}

View File

@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t } from 'elysia'
import { type User } from 'generated/prisma'
import { prisma } from '../lib/prisma'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90 // in seconds
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
const ApiKeyRoute = new Elysia({
prefix: '/apikey',
detail: { tags: ['apikey'] },
})
.post(
'/create',
async ctx => {
const { user }: { user: User } = ctx as any
const { name, description, expiredAt } = ctx.body
const { sign } = (ctx as any).jwt as JWT
// hitung expiredAt
const exp = expiredAt
? Math.floor(new Date(expiredAt).getTime() / 1000) // jika dikirim
: Math.floor(Date.now() / 1000) + NINETY_YEARS // default 90 tahun
const token = await sign({
sub: user.id,
aud: 'host',
exp,
payload: JSON.stringify({
name,
description,
expiredAt,
}),
})
const apiKey = await prisma.apiKey.create({
data: {
name,
description,
key: token,
userId: user.id,
expiredAt: new Date(exp * 1000), // simpan juga di DB biar gampang query
},
})
return { message: 'success', token, apiKey }
},
{
detail: {
summary: 'create api key',
},
body: t.Object({
name: t.String(),
description: t.String(),
expiredAt: t.Optional(t.String({ format: 'date-time' })), // ISO date string
}),
}
)
.get(
'/list',
async ctx => {
const { user }: { user: User } = ctx as any
const apiKeys = await prisma.apiKey.findMany({
where: {
userId: user.id,
},
})
return { message: 'success', apiKeys }
},
{
detail: {
summary: 'get api key list',
},
}
)
.delete(
'/delete',
async ctx => {
const { id } = ctx.body as { id: string }
const apiKey = await prisma.apiKey.delete({
where: {
id,
},
})
return { message: 'success', apiKey }
},
{
detail: {
summary: 'delete api key',
},
body: t.Object({
id: t.String(),
}),
}
)
export default ApiKeyRoute

View File

@@ -0,0 +1,237 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
import { type ElysiaCookie } from 'elysia/cookies'
import { prisma } from '@/server/lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('Missing JWT_SECRET in environment variables')
}
const isProd = process.env.NODE_ENV === 'production'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
type COOKIE = Record<string, Cookie<string | undefined>>
type SET = {
headers: HTTPHeaders
status?: number | keyof StatusMap
redirect?: string
cookie?: Record<string, ElysiaCookie>
}
async function issueToken({
jwt,
cookie,
userId,
role,
expiresAt,
}: {
jwt: JWT
cookie: COOKIE
userId: string
role: 'host' | 'user'
expiresAt: number
}) {
const token = await jwt.sign({
sub: userId,
aud: role,
exp: expiresAt,
})
cookie.token?.set({
value: token,
httpOnly: true,
secure: isProd,
sameSite: 'strict',
maxAge: NINETY_YEARS,
path: '/',
})
return token
}
/* -----------------------
REGISTER FUNCTION
-------------------------*/
async function register({
body,
cookie,
set,
jwt,
}: {
body: { name: string; email: string; password: string }
cookie: COOKIE
set: SET
jwt: JWT
}) {
try {
const { name, email, password } = body
// cek existing user
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
set.status = 400
return { message: 'Email already registered' }
}
// create user
const user = await prisma.user.create({
data: {
name,
email,
password, // plaintext bisa ditambah hash
},
})
return {
success: true,
message: "User registered successfully"
}
} catch (err) {
console.error('Error registering user:', err)
set.status = 500
return {
message: 'Register failed',
error:
err instanceof Error ? err.message : JSON.stringify(err ?? null),
}
}
}
/* -----------------------
LOGIN FUNCTION
-------------------------*/
async function login({
body,
cookie,
set,
jwt,
}: {
body: { email: string; password: string }
cookie: COOKIE
set: SET
jwt: JWT
}) {
try {
const { email, password } = body
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) {
set.status = 401
return { message: 'User not found' }
}
if (user.password !== password) {
set.status = 401
return { message: 'Invalid password' }
}
const token = await issueToken({
jwt,
cookie,
userId: user.id,
role: 'user',
expiresAt: Math.floor(Date.now() / 1000) + NINETY_YEARS,
})
return { token }
} catch (error) {
console.error('Error logging in:', error)
return {
message: 'Login failed',
error:
error instanceof Error ? error.message : JSON.stringify(error ?? null),
}
}
}
/* -----------------------
AUTH ROUTES
-------------------------*/
const Auth = new Elysia({
prefix: '/auth',
detail: { description: 'Auth API', summary: 'Auth API', tags: ['auth'] },
})
.use(
jwtPlugin({
name: 'jwt',
secret,
})
)
/* REGISTER */
.post(
'/register',
async ({ jwt, body, cookie, set }) => {
return await register({
jwt: jwt as JWT,
body,
cookie: cookie as any,
set: set as any,
})
},
{
body: t.Object({
name: t.String(),
email: t.String(),
password: t.String(),
}),
detail: {
description: 'Register new account',
summary: 'register',
},
}
)
/* LOGIN */
.post(
'/login',
async ({ jwt, body, cookie, set }) => {
return await login({
jwt: jwt as JWT,
body,
cookie: cookie as any,
set: set as any,
})
},
{
body: t.Object({
email: t.String(),
password: t.String(),
}),
detail: {
description: 'Login with email + password',
summary: 'login',
},
}
)
/* LOGOUT */
.delete(
'/logout',
({ cookie }) => {
cookie.token?.remove()
return { message: 'Logout successful' }
},
{
detail: {
description: 'Logout (clear token cookie)',
summary: 'logout',
},
}
)
export default Auth

View File

@@ -0,0 +1,26 @@
import Elysia, { t } from "elysia";
import { prisma } from "@/server/lib/prisma";
const Configs = new Elysia({
prefix: "/configs",
detail: { description: "Configs API", summary: "Configs API", tags: ["configs"] },
})
.post("/update-allow-register", async ({ body }) => {
const { allowRegister } = body
await prisma.configs.update({
where: { id: "1" },
data: { allowRegister },
})
return { success: true, message: "Configs updated successfully", allowRegister }
}, {
body: t.Object({
allowRegister: t.Boolean(),
}),
detail: {
description: "Update configs",
summary: "update configs",
},
})
export default Configs

View File

@@ -0,0 +1,261 @@
import Elysia, { t } from "elysia";
import { prisma } from "../lib/prisma";
import { getForDate, getImamIkomahSchedule } from "../lib/jadwalImam";
import DateHolidays from "date-holidays";
import { Coordinates, CalculationMethod, PrayerTimes } from "adhan";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import tz from "dayjs/plugin/timezone";
import "dayjs/locale/id";
dayjs.extend(utc);
dayjs.extend(tz);
dayjs.locale("id");
// Untuk Denpasar: Asia/Makassar (WITA)
const LOCAL_TZ = "Asia/Makassar";
const hd = new DateHolidays("ID");
/* ------------------------------------------------------------------
Helper: standardize date (YYYY-MM-DD) untuk semua input
------------------------------------------------------------------- */
function normalize(dateStr: string) {
return dayjs.tz(dateStr, LOCAL_TZ).format("YYYY-MM-DD");
}
/* ------------------------------------------------------------------
MAIN ROUTER
------------------------------------------------------------------- */
const JadwalSholat = new Elysia({
prefix: "/jadwal-sholat",
tags: ["jadwal_sholat"],
})
/* =============================================================
BULANAN
============================================================= */
.get(
"/bulanan",
async ({ query }) => {
const { day, month, year } = query;
const data = await sebulan(year, month);
return {
date: dayjs.tz(`${year}-${month}-${day}`, LOCAL_TZ).format("YYYY-MM-DD"),
month,
year,
data,
};
},
{
query: t.Object({
day: t.Number({ default: Number(dayjs().tz(LOCAL_TZ).format("DD")) }),
month: t.Number({ default: Number(dayjs().tz(LOCAL_TZ).format("MM")) }),
year: t.Number({ default: Number(dayjs().tz(LOCAL_TZ).format("YYYY")) }),
}),
detail: {
summary: "Get jadwal imam sebulan",
description: "mendapatkan jadwal imam dan ikomah sebulan",
},
}
)
/* =============================================================
HARIAN
============================================================= */
.get(
"/hari",
async ({ query }) => {
const date = normalize(query.date); // fix date shift
const data = await harian({ date, holidays: query.holidays });
return { date, data };
},
{
query: t.Object({
date: t.String({
default: dayjs().tz(LOCAL_TZ).format("YYYY-MM-DD"),
}),
holidays: t.Array(t.String(), { default: [] }),
}),
detail: {
summary: "Get jadwal imam sehari",
description: "mendapatkan jadwal imam dan ikomah sehari",
},
}
)
/* =============================================================
ADHAN — Waktu Solat
============================================================= */
.get(
"/adhan",
({ query }) => {
const date = normalize(query.date);
// Gunakan dayjs untuk membuat "midnight WITA"
const baseDate = dayjs.tz(date + " 00:00", LOCAL_TZ).toDate();
const coords = new Coordinates(query.latitude, query.longitude);
const params = CalculationMethod.MoonsightingCommittee();
const times = new PrayerTimes(coords, baseDate, params);
const toLocal = (dt: Date) =>
dayjs(dt).tz(LOCAL_TZ).format("HH:mm");
return {
date,
latitude: query.latitude,
longitude: query.longitude,
timezone: LOCAL_TZ,
adhan: {
fajr: toLocal(times.fajr),
sunrise: toLocal(times.sunrise),
dhuhr: toLocal(times.dhuhr),
asr: toLocal(times.asr),
maghrib: toLocal(times.maghrib),
isha: toLocal(times.isha),
},
};
},
{
query: t.Object({
date: t.String({
default: dayjs().tz(LOCAL_TZ).format("YYYY-MM-DD"),
}),
latitude: t.Number({ default: -8.6500 }),
longitude: t.Number({ default: 115.2167 }),
}),
detail: {
summary: "Get adhan",
description: "mendapatkan adhan",
},
}
)
.get("/user-list", async () => {
const user = await prisma.user.findMany();
return {
data: user,
};
}, {
detail: {
summary: "Get user list",
description: "mendapatkan list user",
},
})
.put("/user-active", async ({ body }) => {
const { id } = body;
const user = await prisma.user.update({
where: { id },
data: { active: body.active },
});
return {
success: true,
data: user,
};
}, {
body: t.Object({
id: t.String(),
active: t.Boolean(),
}),
detail: {
summary: "Active user",
description: "mengaktifkan user",
},
})
.get("/config", async () => {
const config = await prisma.configs.findUnique({
where: { id: "1" },
});
console.log(config);
return {
data: config,
};
}, {
detail: {
summary: "Get config",
description: "mendapatkan config",
},
})
.put("/config", async ({ body }) => {
const { imamKey, ikomahKey } = body;
const config = await prisma.configs.update({
where: { id: "1" },
data: { imamKey, ikomahKey },
});
console.log({
success: true,
config,
});
return {
success: true,
data: config,
};
}, {
body: t.Object({
id: t.String(),
imamKey: t.String(),
ikomahKey: t.String(),
}),
detail: {
summary: "Update config",
description: "mengupdate config",
},
});
export default JadwalSholat;
/* ------------------------------------------------------------------
FUNCTIONS
------------------------------------------------------------------- */
async function sebulan(year: number, month: number) {
const user = await prisma.user.findMany({ where: { active: true } });
const configs = await prisma.configs.findFirst();
const names = user.map((u) => u.name || "");
return getImamIkomahSchedule({
liburTetap: ["sabtu", "minggu"],
liburNasional: hd.getHolidays(year).map((h) => normalize(h.date)),
names,
year,
month,
keyImam: configs?.imamKey || "defaultImamKey",
keyIkomah: configs?.ikomahKey || "defaultIkomahKey",
});
}
async function harian({
date,
holidays,
}: {
date: string;
holidays?: string[];
}) {
const user = await prisma.user.findMany({ where: { active: true } });
const configs = await prisma.configs.findFirst();
const names = user.map((u) => u.name || "");
// libur nasional ≠ format acak → normalisasi dgn dayjs
const liburNasional = [
...hd.getHolidays(date).map((h) => normalize(h.date)),
...(holidays || []).map((d) => normalize(d)),
];
const liburTetap = ["sabtu", "minggu"];
return getForDate({
names,
date,
keyImam: configs?.imamKey || "defaultImamKey",
keyIkomah: configs?.ikomahKey || "defaultIkomahKey",
liburTetap,
liburNasional,
});
}

36
tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}

8
types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL?: string;
JWT_SECRET?: string;
BUN_PUBLIC_BASE_URL?: string;
PORT?: string;
}
}

24
x.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
search:
image: searxng/searxng:latest
container_name: search
restart: unless-stopped
environment:
- BASE_URL=https://cld-dkr-makuro-search.wibudev.com
- INSTANCE_NAME=WibuSearch
- AUTOCOMPLETE=google
volumes:
- ./data/searxng:/etc/searxng
networks:
- search-network
seafile-frpc:
image: snowdreamtech/frpc:latest
container_name: seafile-frpc
restart: always
volumes:
- ./data/frpc/frpc.toml:/etc/frp/frpc.toml:ro
networks:
- search-network
networks:
search-network:
driver: bridge