first commit
22
.env
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
APP_VERSION=1.2.0
|
||||||
|
APP_NAME=Web CMS
|
||||||
|
|
||||||
|
NEXT_PUBLIC_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# DB Config
|
||||||
|
# MONGODB_URI=mongodb://localhost:27018/
|
||||||
|
MONGODB_URI=mongodb://root:Qawsed%231@localhost:27017/?authSource=admin&retryWrites=true&w=majority
|
||||||
|
MONGODB_DB=web_patra
|
||||||
|
|
||||||
|
NODE_ENV=development
|
||||||
|
# NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_API=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Do not change this
|
||||||
|
ACCESS_TOKEN_SECRET=hDPesQdPflFYz12TXvigker6vrN1HyKQQTPrsjloAbc=
|
||||||
|
REFRESH_TOKEN_SECRET=JMX0eLJypaLLuHFbSahBFSZf3B81V9Hl4NTYNDpcI9U=
|
||||||
|
ENCRYPTION_KEY=hDPesQdPflFYz12TXvigker6vrN1HyKQQTPrsjloAbc=
|
||||||
|
ACCESS_TOKEN_EXPIRATION=1m
|
||||||
|
REFRESH_TOKEN_EXPIRATION=12h
|
||||||
|
ACCESS_TOKEN_MAXAGE=86400
|
||||||
|
REFRESH_TOKEN_MAXAGE=86400
|
||||||
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
This is a core of website with content management system.
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Clone this repo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/freshgalangmandiri/web-cms-fullstack
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
cd web-cms-fullstack
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run Project
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
In general, this is the folder structure of the project.
|
||||||
|
|
||||||
|
```
|
||||||
|
src
|
||||||
|
└── app
|
||||||
|
│ ├── (Admin)
|
||||||
|
│ │ ├── dashboard
|
||||||
|
│ │ │ ├── [...slug]
|
||||||
|
│ │ │ ├── categories/ ...
|
||||||
|
│ │ │ ├── gallery / ...
|
||||||
|
│ │ │ ├── posts / ...
|
||||||
|
│ │ │ ├── tags / ...
|
||||||
|
│ │ │ ├── users / ...
|
||||||
|
│ │ │ ├── users-login / ...
|
||||||
|
│ │ │ ├── dashboard.module.css
|
||||||
|
│ │ │ ├── layout.js
|
||||||
|
│ │ │ ├── not-found.js
|
||||||
|
│ │ │ └── page.js
|
||||||
|
│ │ ├── login / ...
|
||||||
|
│ │ ├── unauthorize / ...
|
||||||
|
│ ├── (Web)
|
||||||
|
│ │ └── post / ...
|
||||||
|
│ ├── api / ...
|
||||||
|
│ ├── forbidden / ...
|
||||||
|
│ ├── library / ....
|
||||||
|
│ ├── favicon.ico
|
||||||
|
│ ├── global-error.js
|
||||||
|
│ ├── globals.css
|
||||||
|
│ ├── layout.js
|
||||||
|
│ ├── manifest.js
|
||||||
|
│ ├── page.js
|
||||||
|
│ ├── page.module.css
|
||||||
|
│ ├── robots.js
|
||||||
|
│ ├── sitemap.js
|
||||||
|
├── components
|
||||||
|
│ ├── administrator / ...
|
||||||
|
│ ├── editor / ...
|
||||||
|
│ ├── google / ...
|
||||||
|
│ ├── ui / ...
|
||||||
|
│ └── web / ...
|
||||||
|
├── config / ...
|
||||||
|
├── data / ...
|
||||||
|
├── hooks / ...
|
||||||
|
├── middlewares / ...
|
||||||
|
├── models / ...
|
||||||
|
├── providers / ...
|
||||||
|
├── utils
|
||||||
|
│ ├── clients.js
|
||||||
|
│ ├── db.config.js
|
||||||
|
│ └── middlewarewrap.js
|
||||||
|
├── server.js
|
||||||
|
├── .env
|
||||||
|
├── .gitignore
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collaboration workflow rules
|
||||||
|
|
||||||
|
#### Standard Types (prefixes):
|
||||||
|
|
||||||
|
- `feature/` → for new features
|
||||||
|
- `fix/` → for bug fixes
|
||||||
|
- `hotfix/` → for urgent production fixes
|
||||||
|
- `chore/` → for maintenance (deps, configs, CI/CD, etc.)
|
||||||
|
- `docs/` → for documentation
|
||||||
|
|
||||||
|
#### Follow the Conventional Commits style Types:
|
||||||
|
|
||||||
|
- `feat` → new feature
|
||||||
|
- `fix` → bug fix
|
||||||
|
- `docs` → documentation only
|
||||||
|
- `style` → code style (formatting, no logic change)
|
||||||
|
- `refactor` → code restructuring without behavior change
|
||||||
|
- `test` → adding or fixing tests
|
||||||
|
- `chore` → build process, tooling, configs
|
||||||
|
|
||||||
|
> Examples:
|
||||||
|
>
|
||||||
|
> 1. `feat(auth): add JWT-based login`
|
||||||
|
> 2. `fix(api): handle null values in user response`
|
||||||
|
> 3. `docs(readme): update setup instructions`
|
||||||
|
> 4. `refactor(db): optimize query for invoices`
|
||||||
|
> 5. `chore(ci): add lint step to GitHub Actions`
|
||||||
8
current.cmd
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
set CURRDIR=%cd%
|
||||||
|
|
||||||
|
start cmd /k "npm run dev"
|
||||||
|
code .
|
||||||
|
|
||||||
|
exit 0
|
||||||
14
eslint.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
jsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
k6.script.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import http from "k6/http";
|
||||||
|
import { sleep } from "k6";
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
vus: 50, // 50 virtual users
|
||||||
|
duration: "30s", // run for 30 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
// http.get("https://cakrabiwa.co.id");
|
||||||
|
http.get("https://new.mediaedutama.co.id");
|
||||||
|
// http.get("http://localhost:3000/post/kelak-kau-akan-menjadi");
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
25
next.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
allowedDevOrigins: ["*.localhost", "*.local", "*.127.0.0.1", "*.0.0.0.0"],
|
||||||
|
images: {
|
||||||
|
minimumCacheTTL: 43200, // 12 hours
|
||||||
|
dangerouslyAllowSVG: true,
|
||||||
|
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
port: "3000",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "placehold.co",
|
||||||
|
port: "",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7435
package-lock.json
generated
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build --turbopack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"dev:socket": "node src/server.js --trace-warnings --no-warnings",
|
||||||
|
"start:socket": "cross-env NODE_ENV=production node src/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/code": "^2.9.4",
|
||||||
|
"@editorjs/delimiter": "^1.4.2",
|
||||||
|
"@editorjs/editorjs": "^2.31.0",
|
||||||
|
"@editorjs/header": "^2.8.8",
|
||||||
|
"@editorjs/image": "^2.10.3",
|
||||||
|
"@editorjs/list": "^2.0.9",
|
||||||
|
"@editorjs/paragraph": "^2.11.7",
|
||||||
|
"@editorjs/quote": "^2.7.6",
|
||||||
|
"@editorjs/table": "^2.4.5",
|
||||||
|
"@next/third-parties": "^16.1.1",
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cross-env": "^10.0.0",
|
||||||
|
"framer-motion": "^12.23.0",
|
||||||
|
"html-react-parser": "^5.2.6",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongodb": "^6.17.0",
|
||||||
|
"next": "^16.1.1",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"recharts": "^3.1.2",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"suneditor-react": "^3.6.1",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
package_copy.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build --turbopack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"framer-motion": "^12.23.0",
|
||||||
|
"html-react-parser": "^5.2.6",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongodb": "^6.17.0",
|
||||||
|
"next": "15.5.0",
|
||||||
|
"react": "19.1.1",
|
||||||
|
"react-dom": "19.1.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"suneditor-react": "^3.6.1",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4662
pnpm-lock.yaml
generated
Normal file
BIN
public/images/login_hero.jpg
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
public/images/no-image.svg
Normal file
BIN
public/logo.jpg
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
public/media/2025/10/1759799830672-Screenshot-(47).png
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
public/media/2025/10/1759799830679-Screenshot-(50).png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
public/media/2025/10/1759799830703-Screenshot-(48).png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
public/media/2025/10/1759799830710-Screenshot-(49).png
Normal file
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/categories/[slug]/category.add.module.css";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getAllCategories, getCategoryById } from "@/models/term";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import CategoryEditor from "@/components/administrator/CategoryEditor/category.editor";
|
||||||
|
import { modifyByKeys } from "@/models/helper";
|
||||||
|
|
||||||
|
const CategoryItem = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const query = await params;
|
||||||
|
const id = query.slug;
|
||||||
|
|
||||||
|
const category = await getCategoryById(id);
|
||||||
|
const categories = await getAllCategories();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard/categories">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<CategoryEditor
|
||||||
|
data={modifyByKeys(category, ["parents"], (parents) =>
|
||||||
|
parents.map((parent) => parent._id)
|
||||||
|
)}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryItem;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
39
src/app/(Admin)/dashboard/(Posts Zone)/categories/page.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/categories/categories.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import { getCategories } from "@/models/term";
|
||||||
|
import CategoriesData from "@/components/administrator/CategoriesData/categories.data";
|
||||||
|
|
||||||
|
const CategoriesPage = async ({ searchParams }) => {
|
||||||
|
const query = await searchParams;
|
||||||
|
const sort = query?.sort ? JSON.parse(query.sort) : { name: 1 };
|
||||||
|
let page = parseInt(query?.page) || 1;
|
||||||
|
const search = query?.search || null;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
if (search) page = 1;
|
||||||
|
|
||||||
|
const categories = await getCategories({ page, limit }, sort, search);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<CategoriesData
|
||||||
|
initialData={categories}
|
||||||
|
limit={limit}
|
||||||
|
sort={sort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesPage;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
92
src/app/(Admin)/dashboard/(Posts Zone)/posts/[slug]/page.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/posts/[slug]/add.module.css";
|
||||||
|
|
||||||
|
import { getPostMaintainById } from "@/models/posts.model";
|
||||||
|
import { getPostCategories, getTags } from "@/models/term";
|
||||||
|
import { getJadwalPelatihanByPostId } from "@/models/jadwal.pelatihan";
|
||||||
|
import { isEmptyObject, spliceObject } from "@/utils/library";
|
||||||
|
import { decodeAccessToken, getCookieByName } from "@/utils/cookie";
|
||||||
|
|
||||||
|
import PostEditor from "@/components/administrator/PostEditor/post.editor";
|
||||||
|
import PostLock from "@/providers/post.lock";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import { getBridgeSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
const PostManagement = async ({ params, searchParams }) => {
|
||||||
|
try {
|
||||||
|
const { slug: _id } = await params;
|
||||||
|
const query = await searchParams;
|
||||||
|
const postData = await getPostMaintainById(_id);
|
||||||
|
const categories = await getPostCategories();
|
||||||
|
const tags = await getTags();
|
||||||
|
const jadwalPelatihanList = await getJadwalPelatihanByPostId(_id);
|
||||||
|
|
||||||
|
const tab = parseInt(query?.tab) || 0;
|
||||||
|
|
||||||
|
// id lock
|
||||||
|
const lockPost = await getCookieByName("lockPost");
|
||||||
|
const user = await decodeAccessToken();
|
||||||
|
|
||||||
|
const currentSatus = postData?.status;
|
||||||
|
if (isEmptyObject(postData)) return notFound();
|
||||||
|
|
||||||
|
// splice post_lock
|
||||||
|
const { selected: posteditor_meta, spliced: data } = spliceObject(
|
||||||
|
postData,
|
||||||
|
["post_lock", "post_publish"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const post_lock = {
|
||||||
|
own: posteditor_meta?.post_lock?.user_id === user?.data?._id,
|
||||||
|
user: posteditor_meta?.post_lock?.user,
|
||||||
|
postLockData: posteditor_meta.post_lock,
|
||||||
|
};
|
||||||
|
|
||||||
|
// get Join Bridge
|
||||||
|
const joinBridge = await getBridgeSettings();
|
||||||
|
|
||||||
|
console.log({ joinBridge });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard/posts">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<PostLock>
|
||||||
|
<PostEditor
|
||||||
|
uid={lockPost}
|
||||||
|
tab={tab}
|
||||||
|
post_lock={post_lock}
|
||||||
|
post_publish={posteditor_meta?.post_publish}
|
||||||
|
currentSatus={currentSatus}
|
||||||
|
categories={categories}
|
||||||
|
tags={tags}
|
||||||
|
data={data}
|
||||||
|
additional_data={{
|
||||||
|
jadwalPelatihanList,
|
||||||
|
joinBridge: joinBridge?.endpoints,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PostLock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log({ error });
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostManagement;
|
||||||
46
src/app/(Admin)/dashboard/(Posts Zone)/posts/page.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/posts/posts.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import { getPosts } from "@/services/posts";
|
||||||
|
import PostsData from "@/components/administrator/PostData/posts.data";
|
||||||
|
import PostLock from "@/providers/post.lock";
|
||||||
|
import { getAllLockPost } from "@/models/posts.model";
|
||||||
|
|
||||||
|
const PostsPage = async ({ searchParams }) => {
|
||||||
|
const querySearch = await searchParams;
|
||||||
|
const page = querySearch?.page || 1;
|
||||||
|
const limit = 50;
|
||||||
|
const posts = await getPosts({ page: parseInt(page), limit });
|
||||||
|
|
||||||
|
// lock post data
|
||||||
|
const lockPostData = await getAllLockPost();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* title, subtitle, description, content, slug, status, categories, tags, image, author
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<PostLock>
|
||||||
|
<PostsData
|
||||||
|
initialData={posts}
|
||||||
|
limit={limit}
|
||||||
|
lockPostData={lockPostData}
|
||||||
|
/>
|
||||||
|
</PostLock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostsPage;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
39
src/app/(Admin)/dashboard/(Posts Zone)/tags/[slug]/page.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/tags/[slug]/tags.add.module.css";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getTagById } from "@/models/term";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import TagEditor from "@/components/administrator/TagEditor/tag.editor";
|
||||||
|
|
||||||
|
const TagItem = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const query = await params;
|
||||||
|
const id = query.slug;
|
||||||
|
|
||||||
|
const tag = await getTagById(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard/tags">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<TagEditor data={tag} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagItem;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
39
src/app/(Admin)/dashboard/(Posts Zone)/tags/page.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/tags/tags.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import TagsData from "@/components/administrator/TagData/tag.data";
|
||||||
|
import { getTagsPagination } from "@/models/term";
|
||||||
|
|
||||||
|
const TagsPage = async ({ searchParams }) => {
|
||||||
|
const query = await searchParams;
|
||||||
|
const sort = query?.sort ? JSON.parse(query.sort) : { name: 1 };
|
||||||
|
let page = parseInt(query?.page) || 1;
|
||||||
|
const search = query?.search || null;
|
||||||
|
const limit = 100;
|
||||||
|
|
||||||
|
if (search) page = 1;
|
||||||
|
|
||||||
|
const tags = await getTagsPagination({ page, limit }, sort, search);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<TagsData
|
||||||
|
initialData={tags}
|
||||||
|
limit={limit}
|
||||||
|
sort={sort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagsPage;
|
||||||
33
src/app/(Admin)/dashboard/(Posts Zone)/tags/tags.module.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
30
src/app/(Admin)/dashboard/(Users)/users-login/page.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Users)/users-login/userslogin.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import { getAllToken, getTokenBy } from "@/models/users";
|
||||||
|
import UsersLoginData from "@/components/administrator/UsersLogin/user.online.data";
|
||||||
|
import { decodeRefreshToken } from "@/utils/cookie";
|
||||||
|
|
||||||
|
const UsersLogin = async () => {
|
||||||
|
const uid = (await decodeRefreshToken()).data?.uid;
|
||||||
|
const allToken = await getAllToken();
|
||||||
|
const activeUsers = allToken.filter((item) => item.uid !== uid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<UsersLoginData initialData={activeUsers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersLogin;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
40
src/app/(Admin)/dashboard/(Users)/users/[slug]/page.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Users)/users/[slug]/user.add.module.css";
|
||||||
|
|
||||||
|
import UserEditor from "@/components/administrator/UserEditor/user.editor";
|
||||||
|
import { getUserById } from "@/services/users";
|
||||||
|
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
|
||||||
|
const UserItem = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const query = await params;
|
||||||
|
const id = query.slug;
|
||||||
|
|
||||||
|
const user = await getUserById(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard/users">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<UserEditor data={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log({ error });
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserItem;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
35
src/app/(Admin)/dashboard/(Users)/users/page.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import styles from "@/app/(Admin)/dashboard/(Users)/users/users.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import UsersData from "@/components/administrator/UserData/user.data";
|
||||||
|
import { getAllUserExcept } from "@/services/users";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { JWT } from "@/utils/jwt";
|
||||||
|
|
||||||
|
const UsersPage = async () => {
|
||||||
|
const jwt = new JWT();
|
||||||
|
const cookie = await cookies();
|
||||||
|
const accessToken = cookie.get("accessToken")?.value;
|
||||||
|
const detail = jwt.decodeToken(accessToken);
|
||||||
|
const users = await getAllUserExcept([detail._id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<UsersData initialData={users} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
33
src/app/(Admin)/dashboard/(Users)/users/users.module.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
6
src/app/(Admin)/dashboard/[...slug]/page.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default function DashboardCatchAll() {
|
||||||
|
// Always call notFound() for unknown routes
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
193
src/app/(Admin)/dashboard/dashboard.module.css
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
.dashboard {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header_container {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytic_box {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytic_box_item {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: .5px solid #ededed;
|
||||||
|
box-shadow: 0 0 12px 1px #2b2b2b1a;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item_data_title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item_data_value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item_icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #e5f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 19px;
|
||||||
|
color: #4085b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytic_charts {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytic_table {
|
||||||
|
width: 500px;
|
||||||
|
min-width: 500px;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 450px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header_container_table {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 30px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header_title_table {
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
p>span {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header_action {
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body_container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_item {
|
||||||
|
border: .5px solid #ededed;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all .2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 12px 1px #2b2b2b1a;
|
||||||
|
border-color: #4085b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
>span {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_action {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
>span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #4085b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/(Admin)/dashboard/gallery/gallery.module.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
43
src/app/(Admin)/dashboard/gallery/page.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import styles from "@/app/(Admin)/dashboard/gallery/gallery.module.css";
|
||||||
|
import GalleryComponent from "@/components/administrator/Gallery/gallery";
|
||||||
|
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
import { getGallery } from "@/models/gallery.model";
|
||||||
|
|
||||||
|
const GalleryPage = async ({ searchParams }) => {
|
||||||
|
const query = await searchParams;
|
||||||
|
let page = query?.page || 1;
|
||||||
|
const limit = 50;
|
||||||
|
const sort = query?.sort ? JSON.parse(query.sort) : { createdAt: -1 };
|
||||||
|
const search = query?.search || null;
|
||||||
|
|
||||||
|
if (search) page = 1;
|
||||||
|
|
||||||
|
const galleryData = await getGallery(
|
||||||
|
{ page: parseInt(page), limit },
|
||||||
|
sort,
|
||||||
|
search
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<GalleryComponent initialData={galleryData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GalleryPage;
|
||||||
34
src/app/(Admin)/dashboard/layout.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./dashboard.module.css";
|
||||||
|
import Sidebar from "@/components/administrator/Sidebar/sidebar";
|
||||||
|
import UserProvider from "@/providers/user.provider";
|
||||||
|
import { EditorProvider } from "@/components/editor/Editor";
|
||||||
|
import { decodeAccessToken } from "@/utils/cookie";
|
||||||
|
import AdminVisitor from "@/providers/admin.visitor";
|
||||||
|
import { description as defaultDescription } from "@/config/default";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
const layout = async ({ children }) => {
|
||||||
|
const accessToken = await decodeAccessToken();
|
||||||
|
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDescription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminVisitor description={description}>
|
||||||
|
<UserProvider userData={accessToken?.data}>
|
||||||
|
<EditorProvider>
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
<Sidebar user={accessToken?.data} />
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</EditorProvider>
|
||||||
|
</UserProvider>
|
||||||
|
</AdminVisitor>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default layout;
|
||||||
73
src/app/(Admin)/dashboard/not-found.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Button from "@/components/ui/Button/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { IoIosArrowRoundBack } from "react-icons/io";
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "15px",
|
||||||
|
fontWeight: "300",
|
||||||
|
width: "100%",
|
||||||
|
}}>
|
||||||
|
Error Code 404
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "50px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}>
|
||||||
|
OOOPS! 😥
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: "15px", fontWeight: "200" }}>
|
||||||
|
This is not the page you are looking for
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: "15px", fontWeight: "400" }}>
|
||||||
|
Maybe you’re lost, let me help you out.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginTop: "20px",
|
||||||
|
fontSize: "13px",
|
||||||
|
padding: "5px 25px",
|
||||||
|
borderRadius: "5px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
backgroundColor: "#3d3d3d",
|
||||||
|
color: "#fff",
|
||||||
|
}}>
|
||||||
|
<IoIosArrowRoundBack size={20} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
136
src/app/(Admin)/dashboard/page.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import ViewRealtime from "@/components/administrator/ViewRealtime/view.realtime";
|
||||||
|
import ViewsChart from "@/components/administrator/ViewsChart/views.chart";
|
||||||
|
import {
|
||||||
|
getAnalytics,
|
||||||
|
newestPost,
|
||||||
|
popularPost,
|
||||||
|
totalCategories,
|
||||||
|
totalDraft,
|
||||||
|
totalPost,
|
||||||
|
totalPostPublished,
|
||||||
|
} from "@/models/analytics";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "@/app/(Admin)/dashboard/dashboard.module.css";
|
||||||
|
import { description as defaultDesctription } from "@/config/default";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { IoDocumentTextOutline, IoEyeOutline } from "react-icons/io5";
|
||||||
|
import { TbCategory2 } from "react-icons/tb";
|
||||||
|
import { RiDraftLine } from "react-icons/ri";
|
||||||
|
import { HiOutlineDocumentDuplicate } from "react-icons/hi2";
|
||||||
|
import { VscShare } from "react-icons/vsc";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
const Dashboard = async ({ searchParams }) => {
|
||||||
|
const query = await searchParams;
|
||||||
|
const viewchart = query?.viewchart ? JSON.parse(query.viewchart) : null;
|
||||||
|
const data = await getAnalytics(viewchart?.timeline, viewchart?.year);
|
||||||
|
|
||||||
|
const popularPostData = await popularPost();
|
||||||
|
const newestPostData = await newestPost();
|
||||||
|
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDesctription;
|
||||||
|
|
||||||
|
const analyticsBox = await Promise.all(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Published Post",
|
||||||
|
get: totalPostPublished,
|
||||||
|
icon: <IoDocumentTextOutline className={styles.icon} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Draft Post",
|
||||||
|
get: totalDraft,
|
||||||
|
icon: <RiDraftLine className={styles.icon} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Post",
|
||||||
|
get: totalPost,
|
||||||
|
icon: <HiOutlineDocumentDuplicate className={styles.icon} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Categories",
|
||||||
|
get: totalCategories,
|
||||||
|
icon: <TbCategory2 className={styles.icon} />,
|
||||||
|
},
|
||||||
|
].map(async (analytic) => {
|
||||||
|
const value = await analytic.get();
|
||||||
|
|
||||||
|
delete analytic.get;
|
||||||
|
return { ...analytic, value };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.header_container}>
|
||||||
|
<div className={styles.header_title}>Dashboard {description.title}</div>
|
||||||
|
<div className={styles.header_action}></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.analytic_box}>
|
||||||
|
{analyticsBox.map((analytic, index) => (
|
||||||
|
<div
|
||||||
|
className={styles.analytic_box_item}
|
||||||
|
key={index}>
|
||||||
|
<div className={styles.item_icon}>{analytic.icon}</div>
|
||||||
|
<div className={styles.item_data}>
|
||||||
|
<div className={styles.item_data_title}>{analytic.title}</div>
|
||||||
|
<div className={styles.item_data_value}>{analytic.value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.analytic_charts}>
|
||||||
|
<ViewsChart initialData={data} />
|
||||||
|
{/* Realtime */}
|
||||||
|
{/* <ViewRealtime /> */}
|
||||||
|
|
||||||
|
{/* Popular post */}
|
||||||
|
<div className={styles.analytic_table}>
|
||||||
|
<div className={styles.header_container_table}>
|
||||||
|
<div className={styles.header_title_table}>
|
||||||
|
<p>
|
||||||
|
<span>Popular Post</span>
|
||||||
|
</p>
|
||||||
|
<p>Top 10 post with most views of all time</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.header_action}></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body_container}>
|
||||||
|
{popularPostData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{popularPostData.map((item, index) => (
|
||||||
|
<div
|
||||||
|
className={styles.table_item}
|
||||||
|
key={index}>
|
||||||
|
<div className={styles.table_content}>
|
||||||
|
<p>{item.title}</p>
|
||||||
|
<span>{item.author.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.table_action}>
|
||||||
|
<span>
|
||||||
|
<IoEyeOutline size={12} /> {item.views}
|
||||||
|
</span>
|
||||||
|
<div className={styles.action}>
|
||||||
|
<VscShare size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
13
src/app/(Admin)/dashboard/settings/general/page.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SettingGeneral from "@/components/administrator/SettingGeneral/setting.general";
|
||||||
|
import { description } from "@/config/default";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
const GeneralSettings = async () => {
|
||||||
|
let result = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!result) result = description;
|
||||||
|
|
||||||
|
return <SettingGeneral generalSetting={result} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeneralSettings;
|
||||||
18
src/app/(Admin)/dashboard/settings/join-bridge/page.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import JoinBridge from "@/components/administrator/JoinBridge/join.bridge";
|
||||||
|
import { getBridgeSettings, getGeneralSettings } from "@/models/settings";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const JoinBridges = async () => {
|
||||||
|
const data = await getBridgeSettings();
|
||||||
|
const general = await getGeneralSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinBridge
|
||||||
|
bridgeSettings={data}
|
||||||
|
status={!!general?.CMS_ID}
|
||||||
|
ID={general?.CMS_ID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JoinBridges;
|
||||||
35
src/app/(Admin)/dashboard/settings/layout.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "@/app/(Admin)/dashboard/settings/setting.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BsArrowLeft } from "react-icons/bs";
|
||||||
|
|
||||||
|
const SettingLayout = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Link
|
||||||
|
className={styles.back}
|
||||||
|
href="/dashboard">
|
||||||
|
<BsArrowLeft /> back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.content_header}>
|
||||||
|
<div className={styles.content_title}>
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<p>
|
||||||
|
Manage your website. Be careful change settings, it can break
|
||||||
|
your website if you don`t know what you are doing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.content_body}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingLayout;
|
||||||
7
src/app/(Admin)/dashboard/settings/page.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
redirect("/dashboard/settings/general");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
75
src/app/(Admin)/dashboard/settings/setting.module.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: calc(100vh - 40px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content_header {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content_title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
>h3 {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content_body {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
68
src/app/(Admin)/login/login.module.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-image: linear-gradient(to top right, #240e00, #823c03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_container {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 900px;
|
||||||
|
height: 500px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.119) 0px 0px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_hero {
|
||||||
|
width: 60%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.5s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
|
||||||
|
.image {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
overflow: hidden;
|
||||||
|
float: left;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: all 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_box {
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 360px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f8feff;
|
||||||
|
}
|
||||||
38
src/app/(Admin)/login/page.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { Suspense } from "react";
|
||||||
|
import styles from "@/app/(Admin)/login/login.module.css";
|
||||||
|
import LoginForm from "@/components/administrator/LoginForm/login.form";
|
||||||
|
import SafeImage from "@/components/ui/SafeImage/safeImage";
|
||||||
|
import { description as defaultDescription } from "@/config/default";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
const Login = async () => {
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDescription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.login_container}>
|
||||||
|
<div className={styles.login_hero}>
|
||||||
|
<div className={styles.login_image}>
|
||||||
|
<SafeImage
|
||||||
|
className={styles.image}
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
src="/images/login_hero.jpg"
|
||||||
|
alt="login"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.login_box}>
|
||||||
|
<Suspense fallback={<div>Preparing</div>}>
|
||||||
|
<LoginForm description={description} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
27
src/app/(Admin)/unauthorize/page.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import styles from "@/app/(Admin)/unauthorize/unauthorize.module.css";
|
||||||
|
|
||||||
|
import { EB_Garamond } from "next/font/google";
|
||||||
|
|
||||||
|
const ebGaramond = EB_Garamond({
|
||||||
|
variable: "--font-eb-garamond",
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const Unauthorize = async () => {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${ebGaramond.className}`}>
|
||||||
|
<div className={styles.error}>
|
||||||
|
<h1 className={styles.code}>401</h1>
|
||||||
|
<span className={styles.line}></span>
|
||||||
|
<p className={styles.message}>Unauthorized</p>
|
||||||
|
<p>You don`t have valid credential to access this page.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Unauthorize;
|
||||||
36
src/app/(Admin)/unauthorize/unauthorize.module.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 80px;
|
||||||
|
display: flex;
|
||||||
|
gap: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding-top: 20px;
|
||||||
|
font-size: 25px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
90
src/app/(Media)/media/[...path]/route.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = Object.fromEntries(searchParams.entries());
|
||||||
|
try {
|
||||||
|
const paths = (await params).path.join("/");
|
||||||
|
const fullpath = path.join(process.cwd(), "public/media", paths);
|
||||||
|
|
||||||
|
await fs.access(fullpath);
|
||||||
|
|
||||||
|
const ext = path.extname(fullpath).toLowerCase();
|
||||||
|
const mimeTypes = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
let imageBuffer = await fs.readFile(fullpath);
|
||||||
|
if (ext !== ".svg") {
|
||||||
|
const width = query.w ? parseInt(query.w) : null;
|
||||||
|
const height = query.h ? parseInt(query.h) : null;
|
||||||
|
|
||||||
|
imageBuffer = await sharp(imageBuffer).resize(width, height).toBuffer();
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(fullpath);
|
||||||
|
|
||||||
|
return new NextResponse(imageBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Content-Length": imageBuffer.length.toString(),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Last-Modified": stat.mtime.toUTCString(),
|
||||||
|
ETag: `"${stat.mtime.getTime()}-${imageBuffer.length}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const width = query.w ? parseInt(query.w) : 400;
|
||||||
|
const height = query.h ? parseInt(query.h) : 200;
|
||||||
|
|
||||||
|
const text = searchParams.get("text") || "Not Found";
|
||||||
|
const bg = searchParams.get("bg") || "#eee";
|
||||||
|
const color = searchParams.get("color") || "#999";
|
||||||
|
|
||||||
|
let fontSize = searchParams.get("font-size")
|
||||||
|
? parseInt(searchParams.get("font-size"))
|
||||||
|
: Math.floor(height * 0.3);
|
||||||
|
|
||||||
|
const factor = 0.6;
|
||||||
|
const estimatedTextWidth = text.length * fontSize * factor;
|
||||||
|
|
||||||
|
if (estimatedTextWidth > width * 0.9) {
|
||||||
|
fontSize = Math.floor((width * 0.9) / (text.length * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
// langsung bikin SVG string
|
||||||
|
const svgImage = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||||
|
<rect width="100%" height="100%" fill="${bg}" />
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="50%"
|
||||||
|
font-size="${fontSize}"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="${color}"
|
||||||
|
font-family="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
${text}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new NextResponse(svgImage, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/(Media)/media/[...path]/route.js.bak
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
try {
|
||||||
|
const paths = (await params).path.join("/");
|
||||||
|
const fullpath = path.join(process.cwd(), "/public/media", paths);
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullpath))
|
||||||
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(fullpath);
|
||||||
|
const filestat = fs.statSync(fullpath);
|
||||||
|
const ext = path.extname(fullpath).toLowerCase();
|
||||||
|
const mimeTypes = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Content-Length": filestat.size.toString(),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Last-Modified": filestat.mtime.toUTCString(),
|
||||||
|
ETag: `"${filestat.mtime.getTime()}-${filestat.size}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/(Web)/post/[slug]/page.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { getPostBySlug } from "@/services/posts";
|
||||||
|
import { getOrigin } from "@/models/helper";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { description as defaultDescription } from "@/config/default";
|
||||||
|
import React from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import RichContent from "@/components/web/RichContent/rich.content";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
// generate metadata
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const param = await params;
|
||||||
|
const slug = param.slug;
|
||||||
|
const post = await getPostBySlug(slug);
|
||||||
|
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDescription;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post.metadata.title + " - " + description.title,
|
||||||
|
description: post.metadata.description,
|
||||||
|
openGraph: {
|
||||||
|
title: post.metadata.title,
|
||||||
|
description: post.metadata.description,
|
||||||
|
images: post.metadata.imagedata.url,
|
||||||
|
type: "article",
|
||||||
|
tags: post.tags.map((tag) => tag.name),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostContent = async ({ params }) => {
|
||||||
|
const param = await params;
|
||||||
|
const slug = param.slug;
|
||||||
|
const post = await getPostBySlug(slug);
|
||||||
|
const origin = await getOrigin();
|
||||||
|
|
||||||
|
if (!post) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* JSON-LD */}
|
||||||
|
<Script
|
||||||
|
id="jsonld"
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
headline: post.metadata.title,
|
||||||
|
alternativeHeadline: post.title,
|
||||||
|
image: post.metadata.imagedata.url,
|
||||||
|
author: "Arena Wisata",
|
||||||
|
keywords: post.tags.map((tag) => tag.name),
|
||||||
|
url: origin,
|
||||||
|
datePublihed: post.createdAt,
|
||||||
|
dateCreated: post.createdAt,
|
||||||
|
dateModified: post.modifiedAt,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Post Component */}
|
||||||
|
<>
|
||||||
|
{post.title}
|
||||||
|
<RichContent html={post.content} />
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostContent;
|
||||||
25
src/app/(Web)/post/sitemap.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import clientPromise, { database } from "@/db.config";
|
||||||
|
import { getOrigin } from "@/models/helper";
|
||||||
|
|
||||||
|
export async function generateSitemaps() {
|
||||||
|
// Fetch the total number of posts and calculate the number of sitemaps needed
|
||||||
|
return [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function sitemap({ id }) {
|
||||||
|
// Google's limit is 50,000 URLs per sitemap
|
||||||
|
const skip = (id - 1) * 50000;
|
||||||
|
const limit = skip + 50000;
|
||||||
|
|
||||||
|
const origin = await getOrigin();
|
||||||
|
const client = await clientPromise;
|
||||||
|
const db = client.db(database);
|
||||||
|
const postCollection = db.collection("posts");
|
||||||
|
|
||||||
|
const posts = await postCollection.find({}).skip(skip).limit(limit).toArray();
|
||||||
|
|
||||||
|
return posts.map((post) => ({
|
||||||
|
url: `${origin}/post/${post.slug}`,
|
||||||
|
lastModified: post.modifiedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
69
src/app/api/admin/gallery/route.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
deleteMedia,
|
||||||
|
getGallery,
|
||||||
|
updateGalleryById,
|
||||||
|
} from "@/models/gallery.model";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const searchParamsData = Object.fromEntries(searchParams.entries());
|
||||||
|
const queryRole = {
|
||||||
|
page: parseInt(searchParamsData?.page) || 1,
|
||||||
|
limit: parseInt(searchParamsData?.limit) || 100,
|
||||||
|
sortBy: searchParamsData?.sortBy || "createdAt",
|
||||||
|
with: searchParamsData?.with || "desc",
|
||||||
|
search: searchParamsData?.search || null,
|
||||||
|
};
|
||||||
|
const sortRole = {
|
||||||
|
asc: 1,
|
||||||
|
desc: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const galleries = await getGallery(
|
||||||
|
{ page: queryRole.page, limit: queryRole.limit },
|
||||||
|
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
|
||||||
|
queryRole.search
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Get gallery data successfully", galleries);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to get galleries data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
if (!data?._id)
|
||||||
|
return response(400, "Bad Request", null, { error: "ID is required" });
|
||||||
|
|
||||||
|
const result = await updateGalleryById(data);
|
||||||
|
|
||||||
|
return response(200, "Update gallery data successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to update data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
body.data.map(async (item) => await deleteMedia(item))
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Delete media successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to Delete Media",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/admin/jadwal-pelatihan/route.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
deleteJadwalPelatihanById,
|
||||||
|
updateJadwalPelatihan,
|
||||||
|
} from "@/models/jadwal.pelatihan";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const result = await updateJadwalPelatihan(body);
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function DELETE(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const _id = searchParams.get("_id");
|
||||||
|
// const body = await request.json();
|
||||||
|
const result = await deleteJadwalPelatihanById(_id);
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/api/admin/posts/[id]/route.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(req, { params }) {
|
||||||
|
const paramsdata = await params;
|
||||||
|
return response(200, "Get post data successfully", paramsdata);
|
||||||
|
}
|
||||||
15
src/app/api/admin/posts/delete/route.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { toTrashMultiple } from "@/models/posts.model";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const result = await toTrashMultiple(data);
|
||||||
|
|
||||||
|
return response(200, "Delete post data successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to delete data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/admin/posts/publish/route.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { unpublish, publishPost } from "@/models/posts.model";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { method, data } = body;
|
||||||
|
|
||||||
|
const on = {
|
||||||
|
unpublish: async (data) => {
|
||||||
|
return Promise.all(data.map(async (id) => await unpublish(id)));
|
||||||
|
},
|
||||||
|
publish: async (data) => {
|
||||||
|
return Promise.all(data.map(async (_data) => await publishPost(_data)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await on[method](data);
|
||||||
|
|
||||||
|
return response(200, `${method} post successfully`, result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to update data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/api/admin/posts/revert/route.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { revertPublish } from "@/models/posts.model";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const result = await revertPublish(data.id);
|
||||||
|
|
||||||
|
return response(200, "Revert post data successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to update data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/admin/posts/route.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { getPosts, updatePostMaintainById } from "@/models/posts.model";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const searchParamsData = Object.fromEntries(searchParams.entries());
|
||||||
|
const queryRole = {
|
||||||
|
page: parseInt(searchParamsData?.page) || 1,
|
||||||
|
limit: parseInt(searchParamsData?.limit) || 100,
|
||||||
|
sortBy: searchParamsData?.sortBy || "createdAt",
|
||||||
|
with: searchParamsData?.with || "desc",
|
||||||
|
search: searchParamsData?.search || null,
|
||||||
|
};
|
||||||
|
const sortRole = {
|
||||||
|
asc: 1,
|
||||||
|
desc: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const posts = await getPosts(
|
||||||
|
{ page: queryRole.page, limit: queryRole.limit },
|
||||||
|
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
|
||||||
|
queryRole.search
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Get post data successfully", posts);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to get post data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const result = await updatePostMaintainById(data);
|
||||||
|
|
||||||
|
return response(200, "Update post data successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to update data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/admin/settings/general/route.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { description } from "@/config/default";
|
||||||
|
import { updateSettings } from "@/models/settings";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = Object.fromEntries(searchParams.entries());
|
||||||
|
let body = await request.json();
|
||||||
|
let unset = null;
|
||||||
|
|
||||||
|
if (query?.setDefault) {
|
||||||
|
unset = { CMS_ID: "" };
|
||||||
|
body = { data: description };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body?.data)
|
||||||
|
return response(400, "Bad Request", null, {
|
||||||
|
error: "Data is unvalid or empty",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateSettings(body.data, "general", unset);
|
||||||
|
|
||||||
|
return response(200, "Set settings successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to set settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/admin/settings/join-bridge/route.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { updateSettings } from "@/models/settings";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
let body = await request.json();
|
||||||
|
|
||||||
|
if (!body?.data)
|
||||||
|
return response(400, "Bad Request", null, {
|
||||||
|
error: "Data is unvalid or empty",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateSettings(body.data, "bridge");
|
||||||
|
|
||||||
|
return response(200, "Set settings successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to set settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/admin/term/categories/delete/route.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { deleteCategoryMultipleId } from "@/models/term";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(body?.data))
|
||||||
|
return response(400, "Bad Request", null, {
|
||||||
|
error: "Data type must be array",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await deleteCategoryMultipleId(body.data);
|
||||||
|
|
||||||
|
return response(200, "Delete category successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to delete data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/admin/term/categories/route.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { getCategories, getChildrenCategoryMultipleId } from "@/models/term";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const searchParamsData = Object.fromEntries(searchParams.entries());
|
||||||
|
const queryRole = {
|
||||||
|
page: parseInt(searchParamsData?.page) || 1,
|
||||||
|
limit: parseInt(searchParamsData?.limit) || 100,
|
||||||
|
sortBy: searchParamsData?.sortBy || "name",
|
||||||
|
with: searchParamsData?.with || "desc",
|
||||||
|
search: searchParamsData?.search || null,
|
||||||
|
};
|
||||||
|
const sortRole = {
|
||||||
|
asc: 1,
|
||||||
|
desc: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories = await getCategories(
|
||||||
|
{
|
||||||
|
page: queryRole.page,
|
||||||
|
limit: queryRole.limit,
|
||||||
|
},
|
||||||
|
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
|
||||||
|
queryRole.search
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Get categories data successfully", categories);
|
||||||
|
} catch (error) {
|
||||||
|
console.log({ error });
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to get categories data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get children of category by id
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body?.data || !Array.isArray(body?.data))
|
||||||
|
return response(400, "Bad Request", null, {
|
||||||
|
error: "Data type must be array",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getChildrenCategoryMultipleId(body.data);
|
||||||
|
|
||||||
|
return response(200, "Get categories data successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to get categories data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/admin/term/tags/add/route.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createTags, getTagsBySlugs } from "@/models/term";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body?.data)
|
||||||
|
return response(400, "Bad Request", null, { error: "Data is required" });
|
||||||
|
|
||||||
|
// find slug is already or not
|
||||||
|
const slugs = body.data.map((item) => item.slug);
|
||||||
|
const tagsExist = await getTagsBySlugs(slugs);
|
||||||
|
|
||||||
|
let unavailableTags = body.data;
|
||||||
|
if (tagsExist.length > 0)
|
||||||
|
unavailableTags = body.data.filter(
|
||||||
|
(item) => !tagsExist.map((tag) => tag.slug).includes(item.slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
// insert new tags
|
||||||
|
let result = [];
|
||||||
|
if (unavailableTags.length > 0) result = await createTags(unavailableTags);
|
||||||
|
|
||||||
|
const mergeResult = [...tagsExist, ...result];
|
||||||
|
return response(200, "Add tag successfully", mergeResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to add tag",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/api/admin/term/tags/delete/route.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { deleteMultipleTag } from "@/models/term";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(body?.data))
|
||||||
|
return response(400, "Bad Request", null, {
|
||||||
|
error: "Data type must be array",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await deleteMultipleTag(body.data);
|
||||||
|
return response(200, "Delete tag successfully", result);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to delete data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/admin/term/tags/route.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { getTagsPagination } from "@/models/term";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const searchParamsData = Object.fromEntries(searchParams.entries());
|
||||||
|
const queryRole = {
|
||||||
|
page: parseInt(searchParamsData?.page) || 1,
|
||||||
|
limit: parseInt(searchParamsData?.limit) || 100,
|
||||||
|
sortBy: searchParamsData?.sortBy || "name",
|
||||||
|
with: searchParamsData?.with || "desc",
|
||||||
|
search: searchParamsData?.search || null,
|
||||||
|
};
|
||||||
|
const sortRole = {
|
||||||
|
asc: 1,
|
||||||
|
desc: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tags = await getTagsPagination(
|
||||||
|
{
|
||||||
|
page: queryRole.page,
|
||||||
|
limit: queryRole.limit,
|
||||||
|
},
|
||||||
|
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
|
||||||
|
queryRole.search
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Get tags data successfully", tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.log({ error });
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to get tags data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const tags = await getTagsPagination(
|
||||||
|
{ page: 1, limit: 100 },
|
||||||
|
{ name: 1 },
|
||||||
|
body.data,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(200, "Get tags data successfully", tags);
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Failed to search tags",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/bridge/auth/route.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
import { Encryption } from "@/utils/encryption";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body?.data || !body?.data?.ID || !body?.data?.ID.startsWith("ICETEA-"))
|
||||||
|
return response(400, "Bad Request", null, { error: "Invalid request" });
|
||||||
|
|
||||||
|
const general = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!general?.CMS_ID)
|
||||||
|
return response(403, "Forbidden", null, {
|
||||||
|
error: "Join bridge not allowed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
ID: body.data.ID,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
OWN_ID: general.CMS_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_KEY = new Encryption().encrypt(JSON.stringify(data));
|
||||||
|
return response(200, "Auth success", { API_KEY });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return response(500, "Internal Server Error", null, {
|
||||||
|
error: "Auth failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/bridge/posts/[bridgeId]/route.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { objectId } from "@/db.config";
|
||||||
|
import { getJadwalPelatihanByPostId } from "@/models/jadwal.pelatihan";
|
||||||
|
import {
|
||||||
|
createPostMaintain,
|
||||||
|
getPostMaintainByBridgeId,
|
||||||
|
} from "@/models/posts.model";
|
||||||
|
import { getPostCategories, getTags } from "@/models/term";
|
||||||
|
import { getUserBridge } from "@/models/users";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
|
||||||
|
export async function GET(request, { params, searchParams }) {
|
||||||
|
try {
|
||||||
|
const { bridgeId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = Object.fromEntries(searchParams.entries());
|
||||||
|
|
||||||
|
let postData = await getPostMaintainByBridgeId(bridgeId);
|
||||||
|
const bridgeUser = await getUserBridge();
|
||||||
|
|
||||||
|
if (!postData) {
|
||||||
|
if (!Object.keys(query).includes("createnew"))
|
||||||
|
return response(404, "Post not found", null);
|
||||||
|
|
||||||
|
postData = await createPostMaintain(bridgeUser._id, {
|
||||||
|
bridgeId: objectId(bridgeId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await getPostCategories();
|
||||||
|
const tags = await getTags();
|
||||||
|
|
||||||
|
// Optional Data
|
||||||
|
const jadwalPelatihan = await getJadwalPelatihanByPostId(postData._id);
|
||||||
|
|
||||||
|
return response(200, "Get Post Data Successfully", {
|
||||||
|
post: postData,
|
||||||
|
tags,
|
||||||
|
categories,
|
||||||
|
additionalData: { jadwalPelatihan },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/api/bridge/route.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
import { Encryption } from "@/utils/encryption";
|
||||||
|
import { response } from "@/utils/helper";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
// get header
|
||||||
|
const header = await headers();
|
||||||
|
const authorization = header.get("authorization");
|
||||||
|
|
||||||
|
if (!authorization)
|
||||||
|
return response(401, "Unauthorized", null, {
|
||||||
|
error: "Authorization not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bearer = authorization.split("Bearer ")[1];
|
||||||
|
|
||||||
|
const general = await getGeneralSettings();
|
||||||
|
const notAllowedRes = response(403, "Forbidden", null, {
|
||||||
|
error: "Join bridge not allowed",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!general?.CMS_ID) return notAllowedRes;
|
||||||
|
|
||||||
|
const decryptedData = new Encryption().decrypt(bearer);
|
||||||
|
const data = JSON.parse(decryptedData);
|
||||||
|
|
||||||
|
if (data?.OWN_ID !== general.CMS_ID) return notAllowedRes;
|
||||||
|
|
||||||
|
return response(200, "Connection Ready", { access: "grant" });
|
||||||
|
} catch (error) {
|
||||||
|
return response(500, "Internal Server Error", null, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/media_deprecated/[...path]/route.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = Object.fromEntries(searchParams.entries());
|
||||||
|
try {
|
||||||
|
const paths = (await params).path.join("/");
|
||||||
|
const fullpath = path.join(process.cwd(), "public/media", paths);
|
||||||
|
|
||||||
|
await fs.access(fullpath);
|
||||||
|
|
||||||
|
const ext = path.extname(fullpath).toLowerCase();
|
||||||
|
const mimeTypes = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
let imageBuffer = await fs.readFile(fullpath);
|
||||||
|
if (ext !== ".svg") {
|
||||||
|
const width = query.w ? parseInt(query.w) : null;
|
||||||
|
const height = query.h ? parseInt(query.h) : null;
|
||||||
|
|
||||||
|
imageBuffer = await sharp(imageBuffer).resize(width, height).toBuffer();
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(fullpath);
|
||||||
|
|
||||||
|
return new NextResponse(imageBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Content-Length": imageBuffer.length.toString(),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Last-Modified": stat.mtime.toUTCString(),
|
||||||
|
ETag: `"${stat.mtime.getTime()}-${imageBuffer.length}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const width = query.w ? parseInt(query.w) : 400;
|
||||||
|
const height = query.h ? parseInt(query.h) : 200;
|
||||||
|
|
||||||
|
const text = searchParams.get("text") || "Not Found";
|
||||||
|
const bg = searchParams.get("bg") || "#eee";
|
||||||
|
const color = searchParams.get("color") || "#999";
|
||||||
|
|
||||||
|
let fontSize = searchParams.get("font-size")
|
||||||
|
? parseInt(searchParams.get("font-size"))
|
||||||
|
: Math.floor(height * 0.3);
|
||||||
|
|
||||||
|
const factor = 0.6;
|
||||||
|
const estimatedTextWidth = text.length * fontSize * factor;
|
||||||
|
|
||||||
|
if (estimatedTextWidth > width * 0.9) {
|
||||||
|
fontSize = Math.floor((width * 0.9) / (text.length * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
// langsung bikin SVG string
|
||||||
|
const svgImage = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||||
|
<rect width="100%" height="100%" fill="${bg}" />
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="50%"
|
||||||
|
font-size="${fontSize}"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="${color}"
|
||||||
|
font-family="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
${text}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new NextResponse(svgImage, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/media_deprecated/[...path]/route.js.bak
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
try {
|
||||||
|
const paths = (await params).path.join("/");
|
||||||
|
const fullpath = path.join(process.cwd(), "/public/media", paths);
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullpath))
|
||||||
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(fullpath);
|
||||||
|
const filestat = fs.statSync(fullpath);
|
||||||
|
const ext = path.extname(fullpath).toLowerCase();
|
||||||
|
const mimeTypes = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Content-Length": filestat.size.toString(),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Last-Modified": filestat.mtime.toUTCString(),
|
||||||
|
ETag: `"${filestat.mtime.getTime()}-${filestat.size}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/posts/heartbeat/route.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import clientPromise, { database, objectId } from "@/db.config";
|
||||||
|
import { normalizeMongoDoc, ProcessPostLock } from "@/models/helper";
|
||||||
|
import { JWT } from "@/utils/jwt";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const jwt = new JWT();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const post_id = searchParams.get("post_id");
|
||||||
|
const uid = searchParams.get("uid");
|
||||||
|
const session = new ProcessPostLock(post_id);
|
||||||
|
|
||||||
|
// cookies
|
||||||
|
const cookies = request.cookies.get("refreshToken")?.value;
|
||||||
|
const userData = jwt.decodeToken(cookies);
|
||||||
|
|
||||||
|
const client = await clientPromise;
|
||||||
|
const db = client.db(database);
|
||||||
|
const postslock = db.collection("posts_lock");
|
||||||
|
|
||||||
|
return await session.execute(async () => {
|
||||||
|
console.log({ post_id, userid: userData._id });
|
||||||
|
|
||||||
|
// check user
|
||||||
|
const currentLock = await postslock.findOne({
|
||||||
|
post_id: objectId(post_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentLock && currentLock.uid !== uid) {
|
||||||
|
const user = await db
|
||||||
|
.collection("users")
|
||||||
|
.findOne(
|
||||||
|
{ _id: objectId(currentLock.user_id) },
|
||||||
|
{ projection: { name: 1 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "unauthorize", user: normalizeMongoDoc(user) },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await postslock.updateOne(
|
||||||
|
{ uid, post_id: objectId(post_id) },
|
||||||
|
{
|
||||||
|
$set: { heartBeat: new Date() },
|
||||||
|
$setOnInsert: {
|
||||||
|
uid,
|
||||||
|
post_id: objectId(post_id),
|
||||||
|
lockedSince: new Date(),
|
||||||
|
user_id: objectId(userData._id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexes = await postslock.listIndexes().toArray();
|
||||||
|
|
||||||
|
if (!indexes.map((i) => i?.name).includes("heartBeat_1"))
|
||||||
|
// create ttl when not update on 10s will be deleted
|
||||||
|
await postslock.createIndex(
|
||||||
|
{ heartBeat: 1 },
|
||||||
|
{ expireAfterSeconds: 10 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ status: "success" }, { status: 200 });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/api/posts/lock/route.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { cleanUpExpiredLockPost, getAllLockPost } from "@/models/posts.model";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// cleanup
|
||||||
|
await cleanUpExpiredLockPost();
|
||||||
|
|
||||||
|
const result = await getAllLockPost();
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to get posts" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/api/posts/realese/route.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import clientPromise, { database, objectId } from "@/db.config";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const post_id = searchParams.get("post_id");
|
||||||
|
const uid = searchParams.get("uid");
|
||||||
|
|
||||||
|
const client = await clientPromise;
|
||||||
|
const db = client.db(database);
|
||||||
|
const postslock = db.collection("posts_lock");
|
||||||
|
|
||||||
|
const result = await postslock.deleteOne({
|
||||||
|
uid,
|
||||||
|
post_id: objectId(post_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ status: "success" }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/upload/gallery/route.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { uploadFile } from "@/utils/upload.file";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const galleryPath = path.join(process.cwd(), "/public/media/gallery");
|
||||||
|
fs.mkdirSync(galleryPath, { recursive: true });
|
||||||
|
const resultUpload = await uploadFile(request, {
|
||||||
|
destinationPath: galleryPath,
|
||||||
|
resize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// db logic
|
||||||
|
|
||||||
|
return NextResponse.json({ resultUpload }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/upload/route.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { uploadFile } from "@/utils/upload.file";
|
||||||
|
import clientPromise, { database } from "@/db.config";
|
||||||
|
import { getOrigin, modifyByKeys } from "@/models/helper";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get("type");
|
||||||
|
const origin = await getOrigin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// uploadFile supports multiple files per request.
|
||||||
|
// but on this endpoint we only support one file
|
||||||
|
const resultUpload = await uploadFile(request, { resize: true });
|
||||||
|
|
||||||
|
if (resultUpload.files.some((file) => file.success === false))
|
||||||
|
throw new Error("Failed to upload file");
|
||||||
|
|
||||||
|
// db logic
|
||||||
|
const client = await clientPromise;
|
||||||
|
const mongo = client.db(database);
|
||||||
|
const media = mongo.collection("media");
|
||||||
|
|
||||||
|
const savetodb = await Promise.all(
|
||||||
|
resultUpload.files.map(async (item) => {
|
||||||
|
const meta = {
|
||||||
|
createdAt: new Date(),
|
||||||
|
url: item.url,
|
||||||
|
title: item.name,
|
||||||
|
filename: item.filename,
|
||||||
|
};
|
||||||
|
const resultDB = await media.insertOne(meta);
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
_id: resultDB.insertedId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...resultUpload,
|
||||||
|
files: resultUpload.files.map((file) => {
|
||||||
|
const data = savetodb.find((item) => item.filename === file.filename);
|
||||||
|
return modifyByKeys(
|
||||||
|
{ ...file, ...data },
|
||||||
|
["url"],
|
||||||
|
// (url) => `${origin}/api${url}`,
|
||||||
|
(url) => `${origin}${url}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// experimental
|
||||||
|
if (type === "mediapost")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ errorMessage: "", resultUpload },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (type === "mediapost")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ errorMessage: "insert error message", result: [] },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
36
src/app/forbidden/forbidden.module.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 80px;
|
||||||
|
display: flex;
|
||||||
|
gap: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding-top: 20px;
|
||||||
|
font-size: 25px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
27
src/app/forbidden/page.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import styles from "@/app/forbidden/forbidden.module.css";
|
||||||
|
|
||||||
|
import { EB_Garamond } from "next/font/google";
|
||||||
|
|
||||||
|
const ebGaramond = EB_Garamond({
|
||||||
|
variable: "--font-eb-garamond",
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const Forbidden = async () => {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${ebGaramond.className}`}>
|
||||||
|
<div className={styles.error}>
|
||||||
|
<h1 className={styles.code}>403</h1>
|
||||||
|
<span className={styles.line}></span>
|
||||||
|
<p className={styles.message}>Forbidden</p>
|
||||||
|
<p>You do not have permission to access this page.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Forbidden;
|
||||||
27
src/app/global-error.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import styles from "@/app/forbidden/forbidden.module.css";
|
||||||
|
import { EB_Garamond } from "next/font/google";
|
||||||
|
|
||||||
|
const ebGaramond = EB_Garamond({
|
||||||
|
variable: "--font-eb-garamond",
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function GlobalError({ error, reset }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div className={`${styles.container} ${ebGaramond.className}`}>
|
||||||
|
<div className={styles.error}>
|
||||||
|
<h1 className={styles.code}>500</h1>
|
||||||
|
<span className={styles.line}></span>
|
||||||
|
<p className={styles.message}>Internal Server Error</p>
|
||||||
|
<p>Sorry, something went wrong. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/globals.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
84
src/app/layout.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { description as defaultDescription } from "@/config/default";
|
||||||
|
import StoreProvider from "@/utils/store/store.provider";
|
||||||
|
import Notification from "@/components/ui/Notification/notification";
|
||||||
|
import Modal from "@/components/administrator/Modal/modal";
|
||||||
|
import Toast from "@/components/ui/Toast/toast";
|
||||||
|
import MiniWindow from "@/components/administrator/MiniWondow/mini.window";
|
||||||
|
import { getOrigin } from "@/models/helper";
|
||||||
|
import VisitorProvider from "@/providers/visitor.provider";
|
||||||
|
import { decodeAccessToken, decodeAnonymous } from "@/utils/cookie";
|
||||||
|
// import { GoogleAnalytics, GoogleTagManager } from "@next/third-parties/google";
|
||||||
|
// import Script from "next/script";
|
||||||
|
import G_TagManager from "@/components/google/google.tag.manager";
|
||||||
|
import G_Analytics from "@/components/google/google.analytics";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
// const geistSans = Geist({
|
||||||
|
// variable: "--font-geist-sans",
|
||||||
|
// subsets: ["latin"],
|
||||||
|
// weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const geistMono = Geist_Mono({
|
||||||
|
// variable: "--font-geist-mono",
|
||||||
|
// subsets: ["latin"],
|
||||||
|
// });
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
variable: "--font-inter",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const origin = await getOrigin();
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDescription;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(origin),
|
||||||
|
title: description.title,
|
||||||
|
description: description.description,
|
||||||
|
openGraph: {
|
||||||
|
title: description.title,
|
||||||
|
description: description.description,
|
||||||
|
images: description.logo,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }) {
|
||||||
|
const accessToken = await decodeAccessToken();
|
||||||
|
const anonymous = await decodeAnonymous();
|
||||||
|
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = defaultDescription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StoreProvider>
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
// className={`${geistSans.variable}`}
|
||||||
|
className={inter.className}>
|
||||||
|
<G_TagManager description={description} />
|
||||||
|
<VisitorProvider
|
||||||
|
description={description}
|
||||||
|
userData={accessToken?.data}
|
||||||
|
anonymous={anonymous?.data}>
|
||||||
|
{children}
|
||||||
|
</VisitorProvider>
|
||||||
|
<MiniWindow />
|
||||||
|
<Notification />
|
||||||
|
<Modal />
|
||||||
|
<Toast />
|
||||||
|
<G_Analytics description={description} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</StoreProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
473
src/app/library/library.js
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
export const isEmptyObject = (obj) =>
|
||||||
|
obj && Object.keys(obj)?.length === 0 && obj.constructor === Object;
|
||||||
|
|
||||||
|
export const isEmptyValue = (obj) =>
|
||||||
|
Object.values(obj).some(
|
||||||
|
(x) => x === "" || x === 0 || x === null || x === undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const formatDate = (date, withClock = false) => {
|
||||||
|
const currentDate = new Date(date);
|
||||||
|
const hours = currentDate.getHours();
|
||||||
|
const minutes = currentDate.getMinutes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentDate.toLocaleDateString("id-ID", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}) +
|
||||||
|
(withClock
|
||||||
|
? ` ${hours.toString().padStart(2, "0")}:${minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`
|
||||||
|
: "")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidDate = (date) => {
|
||||||
|
const currentDate = new Date(date);
|
||||||
|
return !isNaN(currentDate.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spliceObject = (obj, keys = [], removeEmpty = true) => {
|
||||||
|
if (!obj) return;
|
||||||
|
return {
|
||||||
|
selected: Object.entries(obj)
|
||||||
|
.filter(
|
||||||
|
([key, value]) =>
|
||||||
|
keys.includes(key) &&
|
||||||
|
(!removeEmpty || value || typeof value === "boolean")
|
||||||
|
)
|
||||||
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||||
|
spliced: Object.entries(obj)
|
||||||
|
.filter(
|
||||||
|
([key, value]) =>
|
||||||
|
!keys.includes(key) &&
|
||||||
|
(!removeEmpty || value || typeof value === "boolean")
|
||||||
|
)
|
||||||
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPromise = (fn) => fn.constructor.name === "AsyncFunction";
|
||||||
|
|
||||||
|
export const checkPasswordStrength = (password) => {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (!password) return "";
|
||||||
|
|
||||||
|
// Check password length
|
||||||
|
if (password.length > 8) score += 1;
|
||||||
|
// Contains lowercase
|
||||||
|
if (/[a-z]/.test(password)) score += 1;
|
||||||
|
// Contains uppercase
|
||||||
|
if (/[A-Z]/.test(password)) score += 1;
|
||||||
|
// Contains numbers
|
||||||
|
if (/\d/.test(password)) score += 1;
|
||||||
|
// Contains special characters
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score += 1;
|
||||||
|
|
||||||
|
switch (score) {
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
return "Weak";
|
||||||
|
case 3:
|
||||||
|
return "Medium";
|
||||||
|
case 4:
|
||||||
|
case 5:
|
||||||
|
return "Strong";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unmergingCategory = (data) => {
|
||||||
|
if (!data || data?.length === 0) return [];
|
||||||
|
return data
|
||||||
|
.map((item) =>
|
||||||
|
[
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(item).filter(([k, _]) => k !== "children")
|
||||||
|
),
|
||||||
|
item.children.length > 0 ? unmergingCategory(item.children) : null,
|
||||||
|
]
|
||||||
|
.flat()
|
||||||
|
.filter((item) => item)
|
||||||
|
)
|
||||||
|
.flat();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeCategory = (
|
||||||
|
parents,
|
||||||
|
categories = [],
|
||||||
|
currentDepth,
|
||||||
|
depth
|
||||||
|
) => {
|
||||||
|
if (categories.length === 0) return parents;
|
||||||
|
return {
|
||||||
|
...parents,
|
||||||
|
children: (() => {
|
||||||
|
const cat = categories.filter((cat) => cat.parents[0] === parents._id);
|
||||||
|
|
||||||
|
if (currentDepth < depth)
|
||||||
|
return cat.map((c) =>
|
||||||
|
mergeCategory(c, categories, currentDepth + 1, depth)
|
||||||
|
);
|
||||||
|
|
||||||
|
return cat;
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterEmptyTag = (stringdata) => {
|
||||||
|
return stringdata.replace(/<(\w+)[^>]*>(\s| |<br\s*\/?>)*<\/\1>/gi, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debounce = (fn, delay, fnForceDelay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
if (fnForceDelay) fn(...args);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateColor = (alpha = 1) => {
|
||||||
|
const r = Math.floor(Math.random() * 256);
|
||||||
|
const g = Math.floor(Math.random() * 256);
|
||||||
|
const b = Math.floor(Math.random() * 256);
|
||||||
|
const a = Math.round(alpha * 255); // convert alpha (0–1) to 0–255
|
||||||
|
|
||||||
|
// Convert to 2-digit hex and pad with 0 if needed
|
||||||
|
const toHex = (n) => n.toString(16).padStart(2, "0");
|
||||||
|
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description
|
||||||
|
* Group an array of objects by given key.
|
||||||
|
* @param {string} key - The key to group by.
|
||||||
|
* @param {Array} arr - The array to group.
|
||||||
|
* @returns {Object} - An object with grouped objects as values, and the key used as the key.
|
||||||
|
* @example
|
||||||
|
* const data = [
|
||||||
|
* { name: 'John', age: 18 },
|
||||||
|
* { name: 'Jane', age: 19 },
|
||||||
|
* { name: 'John', age: 18 },
|
||||||
|
* { name: 'Jane', age: 19 },
|
||||||
|
* ];
|
||||||
|
* const grouped = arrayGroupBy('name', data);
|
||||||
|
* console.log(grouped);
|
||||||
|
* {
|
||||||
|
* 'John': [
|
||||||
|
* { name: 'John', age: 18 },
|
||||||
|
* { name: 'John', age: 18 }
|
||||||
|
* ],
|
||||||
|
* 'Jane': [
|
||||||
|
* { name: 'Jane', age: 19 },
|
||||||
|
* { name: 'Jane', age: 19 }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const arrayGroupBy = (key, arr) =>
|
||||||
|
arr.length > 0
|
||||||
|
? arr.reduce(
|
||||||
|
(acc, item) =>
|
||||||
|
!!acc[item[key]]
|
||||||
|
? { ...acc, [item[key]]: [...acc[item[key]], item] }
|
||||||
|
: { ...acc, [item[key]]: [item] },
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
export const getImageSize = async (url) => {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: "HEAD" });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
const type = response.headers.get("content-type");
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
const bytes = parseInt(contentLength);
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: bytes,
|
||||||
|
kb: (bytes / 1024).toFixed(2),
|
||||||
|
mb: mb.toFixed(2),
|
||||||
|
size: formatBytes(bytes),
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageDimension = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = isObject(file) ? URL.createObjectURL(file) : file;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === undefined) return "";
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals));
|
||||||
|
return `${value} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slugify = (str) =>
|
||||||
|
str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
|
||||||
|
export const removeDuplicateProp = (data, prop) => {
|
||||||
|
const unique = new Set();
|
||||||
|
return data.filter((item) => {
|
||||||
|
const key = item?.[prop];
|
||||||
|
if (!key || unique.has(key)) return false;
|
||||||
|
|
||||||
|
unique.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toRelativeDay = (date) => {
|
||||||
|
const today = new Date();
|
||||||
|
const targetDate = new Date(date);
|
||||||
|
|
||||||
|
// get diff minutes
|
||||||
|
today.setSeconds(0, 0, 0, 0);
|
||||||
|
targetDate.setSeconds(0, 0, 0, 0);
|
||||||
|
|
||||||
|
let diffTime = targetDate - today;
|
||||||
|
const diffMinutes = Math.abs(Math.ceil(diffTime / (1000 * 60)));
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return "Just now";
|
||||||
|
if (diffMinutes < 59) return `${diffMinutes} minutes ago`;
|
||||||
|
|
||||||
|
// get diff hours
|
||||||
|
today.setMinutes(0, 0, 0, 0);
|
||||||
|
targetDate.setMinutes(0, 0, 0, 0);
|
||||||
|
|
||||||
|
diffTime = targetDate - today;
|
||||||
|
const diffHours = Math.abs(Math.ceil(diffTime / (1000 * 60 * 60)));
|
||||||
|
|
||||||
|
if (diffHours < 1) return "Just now";
|
||||||
|
if (diffHours < 23) return `${diffHours} hours ago`;
|
||||||
|
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
diffTime = targetDate - today;
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ on: diffDays === 0, text: "Today" },
|
||||||
|
{ on: diffDays === 1, text: "Tomorrow" },
|
||||||
|
{ on: diffDays === -1, text: "Yesterday" },
|
||||||
|
{ on: diffDays > 1, text: `in ${Math.abs(diffDays)} days` },
|
||||||
|
{
|
||||||
|
on: diffDays < -1 && diffDays > -7,
|
||||||
|
text: `${Math.abs(diffDays)} days ago`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
on: diffDays <= -7 && diffDays >= -31,
|
||||||
|
text: `${Math.abs(Math.round(diffDays / 7))} weeks ago`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
on: diffDays < -31 && diffDays >= -365,
|
||||||
|
text: `${Math.abs(Math.round(diffDays / 30))} months ago`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
on: diffDays < -365,
|
||||||
|
text: `${formatDate(date)}`,
|
||||||
|
},
|
||||||
|
].find((item) => item.on)?.text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isObject = (obj) =>
|
||||||
|
obj !== null && typeof obj === "object" && !Array.isArray(obj);
|
||||||
|
|
||||||
|
export const flatObjectKeys = (obj) =>
|
||||||
|
isObject(obj)
|
||||||
|
? Object.entries(obj)
|
||||||
|
.map(([k, v]) => [k, flatObjectKeys(v)].flat())
|
||||||
|
.flat()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export const capitalizeEachWord = (string) =>
|
||||||
|
string.replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const objectFilterKey = (obj, key) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.filter(([k, _]) => k !== key)
|
||||||
|
.map(([k, v]) => [k, isObject(v) ? objectFilterKey(v, key) : v])
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description
|
||||||
|
* Check if object has key recursively
|
||||||
|
*/
|
||||||
|
export const objecthasKey = (obj, key) => flatObjectKeys(obj).includes(key);
|
||||||
|
|
||||||
|
export const objecthasKeys = (obj, keys) =>
|
||||||
|
keys.every((key) => objecthasKey(obj, key));
|
||||||
|
|
||||||
|
export const filterEmptyValue = (obj) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.filter(([_, v]) => v)
|
||||||
|
.map(([k, v]) => [k, isObject(v) ? filterEmptyValue(v) : v])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const modifyByKeys = (obj, keys, callback, keepData = false) => {
|
||||||
|
if (!isObject(obj)) return obj;
|
||||||
|
const startOfKey = keys.map((k) => (k.match(/\./gi) ? k.split(".")[0] : k));
|
||||||
|
const endOfKey = keys.map((k) =>
|
||||||
|
k.match(/\./gi) ? k.split(".").slice(-1) : k
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries({
|
||||||
|
...obj,
|
||||||
|
...(keepData
|
||||||
|
? Object.fromEntries(
|
||||||
|
endOfKey.filter((k) => k in obj).map((k) => [`_${k}`, obj[k]])
|
||||||
|
)
|
||||||
|
: {}),
|
||||||
|
}).map(([k, v]) => [
|
||||||
|
k,
|
||||||
|
startOfKey.includes(k)
|
||||||
|
? isObject(v)
|
||||||
|
? modifyByKeys(
|
||||||
|
v,
|
||||||
|
keys
|
||||||
|
.filter((k) => k.match(/\./gi)) // filter only key has dot
|
||||||
|
.map((k) => k.split(".").slice(1).join(".")), // remove first word before dot
|
||||||
|
callback,
|
||||||
|
keepData
|
||||||
|
) // recursively
|
||||||
|
: callback(v)
|
||||||
|
: v,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getByKeys = (obj, keys, initialData = {}) => {
|
||||||
|
// create multidimentional array
|
||||||
|
const newKeys = keys.map((k) => (k.match(/\./gi) ? k.split(".") : k));
|
||||||
|
|
||||||
|
const data = newKeys.reduce(
|
||||||
|
(acc, key) =>
|
||||||
|
// my expectation is key is array obj[key[0]] is object
|
||||||
|
Array.isArray(key) && key[0] in obj
|
||||||
|
? {
|
||||||
|
...acc,
|
||||||
|
// recursively when key is array from splited with dot
|
||||||
|
// call again with joined key with dot
|
||||||
|
...getByKeys(obj[key[0]], [key.slice(1).join(".")], acc),
|
||||||
|
}
|
||||||
|
: key in obj
|
||||||
|
? { ...acc, [key]: obj[key] }
|
||||||
|
: acc,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...initialData, ...data };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateIndexes = (start, end) => {
|
||||||
|
const _start = Math.min(start, end);
|
||||||
|
const _end = Math.max(start, end);
|
||||||
|
|
||||||
|
const length = _end - _start + 1;
|
||||||
|
return Array.from({ length }, (_, index) => index + _start);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateRange = (...numbers) => {
|
||||||
|
const [from, to] = [Math.min(...numbers), Math.max(...numbers)];
|
||||||
|
return Array(to - from + 1)
|
||||||
|
.fill()
|
||||||
|
.map((_, i) => i + from);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateRange = (startDate, endDate) => {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
// helper cek valid date
|
||||||
|
const isValid = (d) => d instanceof Date && !isNaN(d);
|
||||||
|
|
||||||
|
if (!isValid(start)) return startDate;
|
||||||
|
if (!isValid(end)) return endDate;
|
||||||
|
|
||||||
|
const pad = (num) => num.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
const sameDay = start.toDateString() === end.toDateString();
|
||||||
|
const sameMonth = start.getMonth() === end.getMonth();
|
||||||
|
const sameYear = start.getFullYear() === end.getFullYear();
|
||||||
|
|
||||||
|
if (sameDay) {
|
||||||
|
// Kalau start & end sama → tampilkan 1 tanggal
|
||||||
|
return `${pad(start.getDate())} ${start.toLocaleDateString("id-ID", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameMonth && sameYear) {
|
||||||
|
// Contoh: 01 - 05 Januari 2025
|
||||||
|
return `${pad(start.getDate())} - ${pad(
|
||||||
|
end.getDate()
|
||||||
|
)} ${start.toLocaleDateString("id-ID", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}`;
|
||||||
|
} else {
|
||||||
|
// Contoh: 30 Jan - 02 Feb 25
|
||||||
|
return `${pad(start.getDate())} ${start.toLocaleDateString("id-ID", {
|
||||||
|
month: "short",
|
||||||
|
})} - ${pad(end.getDate())} ${end.toLocaleDateString("id-ID", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeURL = (url) => {
|
||||||
|
return url
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/^www\./, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
};
|
||||||
25
src/app/manifest.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { description as tempDesctiption } from "@/config/default";
|
||||||
|
import { getGeneralSettings } from "@/models/settings";
|
||||||
|
|
||||||
|
export default async function manifest() {
|
||||||
|
let description = await getGeneralSettings();
|
||||||
|
|
||||||
|
if (!description) description = tempDesctiption;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: description.title,
|
||||||
|
short_name: description.title,
|
||||||
|
description: description.description,
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#fff",
|
||||||
|
theme_color: "#fff",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/favicon.ico",
|
||||||
|
sizes: "any",
|
||||||
|
type: "image/x-icon",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/app/page.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Button from "@/components/ui/Button/button";
|
||||||
|
import styles from "./page.module.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<Button>press me!</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/page.module.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
13
src/app/robots.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getOrigin } from "@/models/helper";
|
||||||
|
|
||||||
|
export default async function robots() {
|
||||||
|
const origin = await getOrigin();
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: "/dashboard/",
|
||||||
|
},
|
||||||
|
sitemap: `${origin}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/app/services/api/gallery.services.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "@/app/services/config/api.config";
|
||||||
|
|
||||||
|
export const galleryServices = {
|
||||||
|
getGallery: async (
|
||||||
|
{ page = 1, limit = 100 },
|
||||||
|
sort = { createdAt: -1 },
|
||||||
|
search = null
|
||||||
|
) => {
|
||||||
|
const [sortBy, order] = Object.entries(sort)[0];
|
||||||
|
const roleOfSort = { asc: 1, desc: -1 };
|
||||||
|
const orderBy = Object.entries(roleOfSort).find(
|
||||||
|
([_, value]) => value === order
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const params = { page, limit, sortBy, with: orderBy };
|
||||||
|
if (search) params.search = search;
|
||||||
|
|
||||||
|
const response = await api.get("/admin/gallery", { params });
|
||||||
|
|
||||||
|
return response?.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateGalleryById: async (data) => {
|
||||||
|
const response = await api.patch("/admin/gallery", data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMedia: async (id) => {
|
||||||
|
const response = await api.post("/admin/gallery", { data: [id] });
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMediaMultiple: async (data) => {
|
||||||
|
const response = await api.post("/admin/gallery", { data });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
21
src/app/services/api/helper.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { api } = require("../config/api.config");
|
||||||
|
|
||||||
|
export const getData = async (
|
||||||
|
endpoint,
|
||||||
|
{ page = 1, limit = 100 },
|
||||||
|
sort = { name: 1 },
|
||||||
|
search = null
|
||||||
|
) => {
|
||||||
|
const [sortBy, order] = Object.entries(sort)[0];
|
||||||
|
const roleOfSort = { asc: 1, desc: -1 };
|
||||||
|
const orderBy = Object.entries(roleOfSort).find(
|
||||||
|
([_, value]) => value === order
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const params = { page, limit, sortBy, with: orderBy };
|
||||||
|
if (search) params.search = search;
|
||||||
|
|
||||||
|
const response = await api.get(endpoint, { params });
|
||||||
|
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
79
src/app/services/api/posts.services.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "@/app/services/config/api.config";
|
||||||
|
import { getData } from "./helper";
|
||||||
|
|
||||||
|
export const postsServices = {
|
||||||
|
getPosts: async ({ page = 1, limit = 100 }, sort, search) => {
|
||||||
|
const response = await getData(
|
||||||
|
"/admin/posts",
|
||||||
|
{ page, limit },
|
||||||
|
sort,
|
||||||
|
search
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
searchPosts: async (keyword) => {
|
||||||
|
const response = await getData(
|
||||||
|
"/admin/posts",
|
||||||
|
{ page: 1, limit: 200 },
|
||||||
|
{ createdAt: -1 },
|
||||||
|
keyword
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePost: async (data) => {
|
||||||
|
const response = await api.patch("/admin/posts", data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
publishPost: async (data) => {
|
||||||
|
const response = await api.post("/admin/posts/publish", {
|
||||||
|
method: "publish",
|
||||||
|
data: [data],
|
||||||
|
});
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
unpublishPost: async (id) => {
|
||||||
|
const response = await api.post("/admin/posts/publish", {
|
||||||
|
method: "unpublish",
|
||||||
|
data: [id],
|
||||||
|
});
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePost: async (id) => {
|
||||||
|
const response = await api.post("/admin/posts/delete", [id]);
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
revertPublish: async (id) => {
|
||||||
|
const response = await api.post("/admin/posts/revert", { id });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
publishMultiplePost: async (data) => {
|
||||||
|
const response = await api.post("/admin/posts/publish", {
|
||||||
|
method: "publish",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
unpublishMultiplePost: async (data) => {
|
||||||
|
const response = await api.post("/admin/posts/publish", {
|
||||||
|
method: "unpublish",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMultiplePosts: async (data) => {
|
||||||
|
const response = await api.post("/admin/posts/delete", data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
51
src/app/services/api/settings.services.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "@/app/services/config/api.config";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const settingsServices = {
|
||||||
|
updateGeneralSetting: async (data) => {
|
||||||
|
const response = await api.patch("/admin/settings/general", { data });
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultGeneralSetting: async () => {
|
||||||
|
const response = await api.patch(
|
||||||
|
"/admin/settings/general?setDefault=1",
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAuthorization: async (url, ID) => {
|
||||||
|
const protocol = window.location.protocol.replace(":", "");
|
||||||
|
url = url.replace(protocol + "://", "");
|
||||||
|
// only check if url is valid, must return status 200
|
||||||
|
const response = await axios.post(`${protocol}://${url}/api/bridge/auth`, {
|
||||||
|
data: { ID },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...response.data?.data, url: `${protocol}://${url}` };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBridgeSetting: async (data) => {
|
||||||
|
const response = await api.patch("/admin/settings/join-bridge", { data });
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
testBridgeUrl: async (url, api_key) => {
|
||||||
|
const protocol = window.location.protocol.replace(":", "");
|
||||||
|
url = url.replace(protocol + "://", "");
|
||||||
|
// only check if url is valid, must return status 200
|
||||||
|
await axios.get(`${protocol}://${url}/api/bridge`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${api_key}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${protocol}://${url}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
63
src/app/services/api/term.services.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "@/app/services/config/api.config";
|
||||||
|
import { getData } from "./helper";
|
||||||
|
|
||||||
|
export const termServices = {
|
||||||
|
getCategories: async (
|
||||||
|
{ page = 1, limit = 100 },
|
||||||
|
sort = { name: 1 },
|
||||||
|
search = null
|
||||||
|
) => await getData("/admin/term/categories", { page, limit }, sort, search),
|
||||||
|
|
||||||
|
getTags: async (
|
||||||
|
{ page = 1, limit = 100 },
|
||||||
|
sort = { name: 1 },
|
||||||
|
search = null
|
||||||
|
) => await getData("/admin/term/tags", { page, limit }, sort, search),
|
||||||
|
|
||||||
|
getChildrenById: async (id) => {
|
||||||
|
const response = await api.post("/admin/term/categories", { data: [id] });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getChildMultipleId: async (ids) => {
|
||||||
|
const response = await api.post("/admin/term/categories", { data: ids });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
searchTermById: async (ids) => {
|
||||||
|
const response = await api.post("/admin/term/tags", { data: ids });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createMultipleTags: async (data) => {
|
||||||
|
const response = await api.post("/admin/term/tags/add", { data: data });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCategoryById: async (id) => {
|
||||||
|
const response = await api.post("/admin/term/categories/delete", {
|
||||||
|
data: [id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deteleMultipleCategories: async (ids) => {
|
||||||
|
const response = await api.post("/admin/term/categories/delete", {
|
||||||
|
data: ids,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTagById: async (id) => {
|
||||||
|
const response = await api.post("/admin/term/tags/delete", { data: [id] });
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMultipleTag: async (ids) => {
|
||||||
|
const response = await api.post("/admin/term/tags/delete", { data: ids });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
18
src/app/services/config/api.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_API || "http://localhost:3000/api",
|
||||||
|
timeout: 10000,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
13
src/app/sitemap.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { webPages } from "@/config/web";
|
||||||
|
import { getOrigin } from "@/models/helper";
|
||||||
|
|
||||||
|
export default async function sitemap() {
|
||||||
|
const origin = await getOrigin();
|
||||||
|
|
||||||
|
return webPages.map((page) => ({
|
||||||
|
url: `${origin}${page.route}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: page.frequency,
|
||||||
|
priority: page.priority || 0.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
41
src/clients.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export class Clients {
|
||||||
|
constructor() {
|
||||||
|
this.sockets = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
addSocket(userId, socket, data) {
|
||||||
|
this.sockets[userId] = { data: data || {}, socket, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData(userId, data) {
|
||||||
|
this.sockets[userId].data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTo(userId, event, data) {
|
||||||
|
const socket = this.sockets[userId].socket;
|
||||||
|
if (socket) {
|
||||||
|
socket.emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(event, data) {
|
||||||
|
if (!Object.keys(this.sockets).length) return;
|
||||||
|
Object.keys(this.sockets).forEach((userId) =>
|
||||||
|
this.sendTo(userId, event, data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listData() {
|
||||||
|
return Object.values(this.sockets).map((item) => {
|
||||||
|
return { userId: item.userId, data: item.data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listUsers() {
|
||||||
|
return Object.keys(this.sockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSocket(userId) {
|
||||||
|
delete this.sockets[userId];
|
||||||
|
}
|
||||||
|
}
|
||||||