first commit

This commit is contained in:
2025-12-30 14:38:36 +07:00
commit ed079f44b9
255 changed files with 40351 additions and 0 deletions

22
.env Normal file
View 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:example@localhost:27017/?authSource=admin&retryWrites=true&w=majority
MONGODB_DB=web
# 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

14
k6.script.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
public/images/no-image.svg Normal file

Binary file not shown.

BIN
public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View 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;
}

View File

@@ -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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View File

@@ -0,0 +1,6 @@
import { notFound } from "next/navigation";
export default function DashboardCatchAll() {
// Always call notFound() for unknown routes
return notFound();
}

View 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;
}
}

View 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;
}

View 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;

View 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 { decodeAccessToken } from "@/utils/cookie";
import AdminVisitor from "@/providers/admin.visitor";
import { description as defaultDescription } from "@/config/default";
import { getGeneralSettings } from "@/models/settings";
import { EditorProvider } from "@/components/administrator/EditorMD/provider";
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;

View 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 youre 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
const Settings = () => {
redirect("/dashboard/settings/general");
};
export default Settings;

View 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;
}

View 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;
}

View 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;

View 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;

View 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;
}

View 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",
},
});
}
}

View 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 });
}
}

View 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;

View 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,
}));
}

View 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",
});
}
}

View 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 });
}
}

View 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);
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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",
});
}
}

View 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 });
}
}

View 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 });
}
}

View 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",
},
});
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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
View 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
View 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
View 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
View 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
View 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|&nbsp;|<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 (01) to 0255
// 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
View 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
View 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
View 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
View 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`,
};
}

View 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;
},
};

View 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;
};

View 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;
},
};

View 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}`;
},
};

View 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;
},
};

View 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
View 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,
}));
}

Some files were not shown because too many files have changed in this diff Show More