baru ni e
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal 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
42
.gitignore
vendored
Normal 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
138
README.md
Normal 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
53
bin/env.generate.ts
Normal 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
416
bin/route.generate.ts
Normal 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
17
bun-env.d.ts
vendored
Normal 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
528
bun.lock
Normal 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
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[serve.static]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
57
package.json
Normal file
57
package.json
Normal 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
16
postcss.config.js
Normal 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
48
prisma/schema.prisma
Normal 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
74
prisma/seed.ts
Normal 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
19
src/App.tsx
Normal 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
258
src/AppRoutes.tsx
Normal 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
719
src/Landing.tsx
Normal 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
19
src/clientRoutes.ts
Normal 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
33
src/components/Logout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/ProtectedRoute.tsx
Normal file
25
src/components/ProtectedRoute.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
30
src/components/RoleRoute.tsx
Normal file
30
src/components/RoleRoute.tsx
Normal 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
26
src/frontend.tsx
Normal 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
187
src/index.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.05;
|
||||||
|
background: url("./logo.svg");
|
||||||
|
background-size: 256px;
|
||||||
|
transform: rotate(-12deg) scale(1.35);
|
||||||
|
animation: slide 30s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes slide {
|
||||||
|
from {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 256px 224px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 0.3s;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.bun-logo {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.bun-logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #fbf0dfaa);
|
||||||
|
}
|
||||||
|
.react-logo {
|
||||||
|
animation: spin 20s linear infinite;
|
||||||
|
}
|
||||||
|
.react-logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.api-tester {
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.endpoint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font: monospace;
|
||||||
|
border: 2px solid #fbf0df;
|
||||||
|
transition: 0.3s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.endpoint-row:focus-within {
|
||||||
|
border-color: #f3d5a3;
|
||||||
|
}
|
||||||
|
.method {
|
||||||
|
background: #fbf0df;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9em;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
width: min-content;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.method option {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.url-input {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
background: 0;
|
||||||
|
border: 0;
|
||||||
|
color: #fbf0df;
|
||||||
|
font: 1em monospace;
|
||||||
|
padding: 0.2rem;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.url-input:focus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: rgba(251, 240, 223, 0.4);
|
||||||
|
}
|
||||||
|
.send-button {
|
||||||
|
background: #fbf0df;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: 0.1s;
|
||||||
|
cursor: var(--bun-cursor);
|
||||||
|
}
|
||||||
|
.send-button:hover {
|
||||||
|
background: #f3d5a3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.response-area {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #fbf0df;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #fbf0df;
|
||||||
|
font: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.response-area:focus {
|
||||||
|
border-color: #f3d5a3;
|
||||||
|
}
|
||||||
|
.response-area::placeholder {
|
||||||
|
color: rgba(251, 240, 223, 0.4);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/index.html
Normal file
13
src/index.html
Normal 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
137
src/index.tsx
Normal 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
11
src/lib/apiFetch.ts
Normal 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
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
31
src/pages/Home.tsx
Normal file
31
src/pages/Home.tsx
Normal 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
100
src/pages/Login.tsx
Normal 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
19
src/pages/NotFound.tsx
Normal 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
134
src/pages/Register.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/pages/dashboard/apikey/apikey_page.tsx
Normal file
232
src/pages/dashboard/apikey/apikey_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/dashboard/config/config_layout.tsx
Normal file
5
src/pages/dashboard/config/config_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ConfigLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
60
src/pages/dashboard/config/config_page.tsx
Normal file
60
src/pages/dashboard/config/config_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/pages/dashboard/dashboard_layout.tsx
Normal file
203
src/pages/dashboard/dashboard_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/pages/dashboard/dashboard_page.tsx
Normal file
118
src/pages/dashboard/dashboard_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/dashboard/shalat/dashboard-shalat_layout.tsx
Normal file
5
src/pages/dashboard/shalat/dashboard-shalat_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function JadwalShalat() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
161
src/pages/dashboard/shalat/dashboard-shalat_page.tsx
Normal file
161
src/pages/dashboard/shalat/dashboard-shalat_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/profile/Profile_layout.tsx
Normal file
12
src/pages/profile/Profile_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/pages/profile/Profile_page.tsx
Normal file
11
src/pages/profile/Profile_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/shalat/shalat_layout.tsx
Normal file
5
src/pages/shalat/shalat_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function AdhanLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
766
src/pages/shalat/shalat_page.tsx
Normal file
766
src/pages/shalat/shalat_page.tsx
Normal 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
8
src/react.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||||
|
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||||
|
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||||
|
<ellipse rx="11" ry="4.2"/>
|
||||||
|
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||||
|
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 338 B |
11
src/routeTypes.ts
Normal file
11
src/routeTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
228
src/server/lib/jadwalImam.ts
Normal file
228
src/server/lib/jadwalImam.ts
Normal 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 Fisher–Yates 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
11
src/server/lib/prisma.ts
Normal 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
|
||||||
|
}
|
||||||
84
src/server/middlewares/apiAuth.ts
Normal file
84
src/server/middlewares/apiAuth.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
105
src/server/routes/apikey_route.ts
Normal file
105
src/server/routes/apikey_route.ts
Normal 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
|
||||||
237
src/server/routes/auth_route.ts
Normal file
237
src/server/routes/auth_route.ts
Normal 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
|
||||||
26
src/server/routes/configs_route.ts
Normal file
26
src/server/routes/configs_route.ts
Normal 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
|
||||||
|
|
||||||
261
src/server/routes/jadwal_sholat.ts
Normal file
261
src/server/routes/jadwal_sholat.ts
Normal 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
36
tsconfig.json
Normal 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
8
types/env.d.ts
vendored
Normal 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
24
x.yml
Normal 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
|
||||||
Reference in New Issue
Block a user