first commit
This commit is contained in:
@@ -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
36
src/app/forbidden/forbidden.module.css
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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];
|
||||
}
|
||||
}
|
||||
261
src/components/administrator/CategoriesData/categories.data.js
Normal file
261
src/components/administrator/CategoriesData/categories.data.js
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import styles from "@/components/administrator/CategoriesData/categoriesdata.module.css";
|
||||
|
||||
import Pagination from "@/components/ui/Pagination/pagination";
|
||||
import Loader from "@/components/web/Loader/loader";
|
||||
import Table from "@/components/administrator/Table/table";
|
||||
import NewCategory from "../NewCategory/new.category";
|
||||
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import { useTerm } from "@/hooks/useTerm";
|
||||
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import { IoCloseOutline } from "react-icons/io5";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { termServices } from "@/app/services/api/term.services";
|
||||
|
||||
/**
|
||||
* @type {React.FC <{
|
||||
* initialData: any[];
|
||||
* limit: number;
|
||||
* sort?: object;
|
||||
* }>}
|
||||
* @returns
|
||||
*/
|
||||
const CategoriesData = (props) => {
|
||||
const searchref = useRef(null);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [initial, setInitial] = useState(props.initialData);
|
||||
const [categories, setCategories] = useState(props.initialData.data);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState({
|
||||
data: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const sort = useMemo(() => {
|
||||
try {
|
||||
const SORT = Object.fromEntries(searchParams.entries())?.sort;
|
||||
setCurrentPage(1);
|
||||
return JSON.parse(SORT);
|
||||
} catch (error) {
|
||||
return props.sort;
|
||||
}
|
||||
}, [searchParams, props.sort]);
|
||||
|
||||
const categoriesData = useMemo(() => {
|
||||
return categories?.map((category) => ({
|
||||
...category,
|
||||
hierarchy: (() => {
|
||||
const cat = Array.from(category.parents);
|
||||
if (cat?.length > 0)
|
||||
return cat
|
||||
.reverse()
|
||||
.map((item) => item.name)
|
||||
.join(" > ");
|
||||
return null;
|
||||
})(),
|
||||
}));
|
||||
}, [categories]);
|
||||
|
||||
const {
|
||||
isLoading: loadSearch,
|
||||
isSearch,
|
||||
handleInput,
|
||||
handleSubmit,
|
||||
setInitialData,
|
||||
setSearchFn,
|
||||
setSetDataFn,
|
||||
setFallbackFn,
|
||||
cancel,
|
||||
} = useSearch();
|
||||
const { deleteMultipleCategory } = useTerm();
|
||||
|
||||
const getSortedData = useCallback(async ({ page, limit }, sort) => {
|
||||
const data = await termServices.getCategories({ page, limit }, sort);
|
||||
setCategories(data.data);
|
||||
}, []);
|
||||
|
||||
const setData = useCallback((data) => {
|
||||
setCategories(data);
|
||||
}, []);
|
||||
|
||||
const onDeletedItem = useCallback(async () => {
|
||||
setSelected([]);
|
||||
const data = await termServices.getCategories({ page: 1, limit: 50 }, sort);
|
||||
|
||||
setCategories(data.data);
|
||||
setInitial(data);
|
||||
cancel();
|
||||
}, [sort, cancel]);
|
||||
|
||||
const fallback = useCallback(async (keyword, limit = 100) => {
|
||||
const result = await termServices.getCategories(
|
||||
{ page: 1, limit },
|
||||
{ name: 1 },
|
||||
keyword
|
||||
);
|
||||
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading((prev) => ({ ...prev, data: loadSearch }));
|
||||
}, [loadSearch]);
|
||||
|
||||
return (
|
||||
<div className={styles.post_container}>
|
||||
<div className={styles.post_header}>
|
||||
<div className={styles.header_wrapper}>
|
||||
<h1>Category Management</h1>
|
||||
<div className={styles.post_action}>
|
||||
{selected.length > 0 && (
|
||||
<div className={styles.post_status}>
|
||||
<p>
|
||||
Selected:{" "}
|
||||
<span>
|
||||
{selected.length} item{selected.length > 1 && "s"}
|
||||
</span>{" "}
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className={styles.action}
|
||||
style={
|
||||
isLoading.delete
|
||||
? { opacity: 0.5, pointerEvents: "none" }
|
||||
: {}
|
||||
}
|
||||
onClick={async () => {
|
||||
setIsLoading((prev) => ({ ...prev, delete: true }));
|
||||
deleteMultipleCategory(
|
||||
selected.map((item) => item._id),
|
||||
async () => onDeletedItem(),
|
||||
() =>
|
||||
setIsLoading((prev) => ({ ...prev, delete: false }))
|
||||
);
|
||||
}}>
|
||||
{isLoading.delete ? (
|
||||
`Preparing to delete ${selected.length} item`
|
||||
) : (
|
||||
<>Delete item{selected.length > 1 && "s"}</>
|
||||
)}
|
||||
</span>{" "}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<NewCategory
|
||||
className={styles.post_button}
|
||||
fontSize={13}
|
||||
getCategory={(result) => {
|
||||
setCategories((prev) => [result, ...prev]);
|
||||
}}>
|
||||
Add Category
|
||||
</NewCategory>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.header_wrapper}>
|
||||
<form
|
||||
className={styles.search_container}
|
||||
onSubmit={handleSubmit}>
|
||||
<FiSearch />
|
||||
<input
|
||||
type="text"
|
||||
ref={searchref}
|
||||
placeholder="Search Categories"
|
||||
defaultValue={props.initialData?.keyword || ""}
|
||||
onInput={handleInput}
|
||||
onFocus={() => {
|
||||
if (!isSearch) setInitialData(categories);
|
||||
|
||||
setSearchFn(fallback);
|
||||
setFallbackFn(async () => {
|
||||
setIsLoading((prev) => ({ ...prev, data: true }));
|
||||
const data = await fallback(null, props?.limit);
|
||||
setIsLoading((prev) => ({ ...prev, data: false }));
|
||||
setData(data);
|
||||
});
|
||||
setSetDataFn(setData);
|
||||
}}
|
||||
/>
|
||||
{isSearch && (
|
||||
<IoCloseOutline
|
||||
onClick={() => {
|
||||
searchref.current.value = "";
|
||||
cancel();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading.data ? (
|
||||
<div className={styles.loader_container}>
|
||||
<div className={styles.loader}>
|
||||
<Loader
|
||||
color="#000"
|
||||
width={30}
|
||||
/>
|
||||
<p>Getting data</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={categoriesData}
|
||||
selected={selected}
|
||||
limit={props.limit}
|
||||
role={{
|
||||
name: "Name",
|
||||
slug: "Slug",
|
||||
hierarchy: "Hierarchy",
|
||||
description: "Description",
|
||||
}}
|
||||
sortRole={{
|
||||
canSorting: true,
|
||||
excludeSorting: ["hierarchy"],
|
||||
sortingFn: getSortedData,
|
||||
}}
|
||||
onClick={(data) => {
|
||||
router.push(`/dashboard/categories/${data._id}`);
|
||||
}}
|
||||
onSelected={(data) => {
|
||||
setSelected(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSearch && (
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
totalPage={initial.totalPage}
|
||||
currentPage={(() => {
|
||||
const page = parseInt(
|
||||
Object.fromEntries(searchParams.entries())?.page
|
||||
);
|
||||
return page || initial.page;
|
||||
})()}
|
||||
getData={termServices.getCategories}
|
||||
getIsLoading={(status) =>
|
||||
setIsLoading((prev) => ({ ...prev, data: status }))
|
||||
}
|
||||
setData={(data) => setCategories(data.data)}
|
||||
limit={props.limit}
|
||||
sort={sort}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesData;
|
||||
@@ -0,0 +1,123 @@
|
||||
.post_container {
|
||||
height: fit-content;
|
||||
/* min-height: 100vh; */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.post_header {
|
||||
height: fit-content;
|
||||
background-color: #f5f7fa;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.header_wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
>h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.post_action {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.post_status {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* .post_button {
|
||||
padding: 7px 15px !important;
|
||||
background-color: #3d3d3d !important;
|
||||
font-size: 13px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
} */
|
||||
|
||||
.search_container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
padding: 15px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
|
||||
>input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loader_container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 230px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
>p {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
291
src/components/administrator/CategoryEditor/category.editor.js
Normal file
291
src/components/administrator/CategoryEditor/category.editor.js
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import styles from "@/components/administrator/CategoryEditor/categoryeditor.module.css";
|
||||
import Input from "@/components/administrator/form/Input/input";
|
||||
import TextArea from "@/components/administrator/form/TextArea/text.area";
|
||||
import {
|
||||
modifyByKeys,
|
||||
slugify,
|
||||
unmergingCategory,
|
||||
} from "@/app/library/library";
|
||||
import Button from "@/components/ui/Button/button";
|
||||
import Uploadbox from "../form/UploadBox/upload.box";
|
||||
import Dropdown from "../form/Dropdown/dropdown";
|
||||
import { useTerm } from "@/hooks/useTerm";
|
||||
import Loader from "@/components/web/Loader/loader";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {React.FC <{
|
||||
* data: object;
|
||||
* }>}
|
||||
* @returns
|
||||
*/
|
||||
const CategoryEditor = (props) => {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState(props.data);
|
||||
const [slugLock, setSlugLock] = useState(true);
|
||||
const [media, setMedia] = useState(props.data?.media);
|
||||
// onsave data loading
|
||||
const [loading, setLoading] = useState({
|
||||
save: false,
|
||||
delete: false,
|
||||
});
|
||||
const { mergeCategoryPost, updateCategory, deleteCategory } = useTerm();
|
||||
|
||||
const isChange = useMemo(() => {
|
||||
return JSON.stringify(data) !== JSON.stringify(props.data);
|
||||
}, [props.data, data]);
|
||||
|
||||
const categoryUnmerge = useMemo(() => {
|
||||
const cat = unmergingCategory(props.categories);
|
||||
const filtered = cat.filter((category) => category._id !== data._id);
|
||||
return filtered;
|
||||
}, [props.categories, data]);
|
||||
|
||||
// filter parent if exist
|
||||
const categoryMerge = useMemo(() => {
|
||||
return mergeCategoryPost(categoryUnmerge);
|
||||
}, [categoryUnmerge, mergeCategoryPost]);
|
||||
|
||||
const hierarchy = useMemo(() => {
|
||||
if (data?.parents && data?.parents?.length > 0) {
|
||||
const dataParents = Array.from(data.parents);
|
||||
return dataParents
|
||||
.reverse()
|
||||
.map((id) => categoryUnmerge.find((cat) => cat._id === id));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [categoryUnmerge, data]);
|
||||
|
||||
const currentParent = useMemo(() => {
|
||||
return data?.parents && data.parents.length > 0
|
||||
? categoryUnmerge.find((cat) => cat._id === data.parents?.[0])
|
||||
: null;
|
||||
}, [categoryUnmerge, data]);
|
||||
|
||||
const setImage = useCallback((file, uid) => {
|
||||
const id = file?._id || file?.files?.[0]?._id;
|
||||
setMedia(file);
|
||||
setTimeout(() => {
|
||||
setData((prev) => ({ ...prev, image: id }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback(() => {
|
||||
setMedia(null);
|
||||
setTimeout(() => {
|
||||
setData((prev) => ({ ...prev, image: null }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSetParent = useCallback((item, e) => {
|
||||
const newparents = [item._id, ...(item?.parents ? item.parents : [])];
|
||||
setData((prev) => ({ ...prev, parents: newparents }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setLoading((prev) => ({ ...prev, save: true }));
|
||||
updateCategory(data, (result) => {
|
||||
setLoading((prev) => ({ ...prev, save: false }));
|
||||
if (result.media) setMedia(result.media);
|
||||
delete result.media;
|
||||
|
||||
setData(
|
||||
modifyByKeys(result, ["parents"], (parents) =>
|
||||
parents.map((parent) => parent._id)
|
||||
)
|
||||
);
|
||||
});
|
||||
}, [data, updateCategory]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setLoading((prev) => ({ ...prev, delete: true }));
|
||||
deleteCategory(
|
||||
data._id,
|
||||
(result) => {
|
||||
setLoading((prev) => ({ ...prev, delete: false }));
|
||||
router.push("/dashboard/categories");
|
||||
},
|
||||
() => {
|
||||
setLoading((prev) => ({ ...prev, delete: false }));
|
||||
}
|
||||
);
|
||||
}, [data, deleteCategory, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
const keyDown = [
|
||||
{
|
||||
on: e.key === "s" && e.ctrlKey,
|
||||
call: async () => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
},
|
||||
},
|
||||
{
|
||||
on: e.key === "Delete" && e.shiftKey,
|
||||
call: async () => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return keyDown.find((key) => key.on)?.call();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleSave, handleDelete]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={styles.post_title}>{data.name || "Unnamed Category"}</h1>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.editor_header}>
|
||||
<div className={styles.editor_status}>
|
||||
<p>
|
||||
ID : <span>{props.data._id}</span>
|
||||
</p>
|
||||
<p>
|
||||
Term : <span>Categories</span>
|
||||
</p>
|
||||
<p>
|
||||
Status : <span>{isChange ? "Changed" : "Unchanged"}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.editor_status}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!isChange || loading.save}
|
||||
onClick={() => handleSave()}>
|
||||
{loading.save && (
|
||||
<>
|
||||
<Loader
|
||||
size={20}
|
||||
color={"#fff"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={loading.delete}
|
||||
onClick={() => handleDelete()}>
|
||||
{loading.delete && (
|
||||
<>
|
||||
<Loader
|
||||
size={20}
|
||||
color={"#fff"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.editor_content}>
|
||||
<div className={`${styles.editor_body}`}>
|
||||
<div className={styles.editor_post}>
|
||||
<Input
|
||||
label="Name"
|
||||
className={styles.input}
|
||||
value={data.name || ""}
|
||||
labelclassname={styles.label}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
slug: slugify(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<TextArea
|
||||
label="Description"
|
||||
className={styles.input}
|
||||
labelclassname={styles.label}
|
||||
defaultValue={data.description || ""}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
label="Parent"
|
||||
labelclassname={styles.label}
|
||||
className={styles.input}
|
||||
data={categoryMerge}
|
||||
display="name"
|
||||
value={currentParent}
|
||||
format={{
|
||||
label: "Set to Top Level",
|
||||
action: (item, setItem) => {
|
||||
setItem("");
|
||||
setData((prev) => ({ ...prev, parents: [] }));
|
||||
},
|
||||
changeValue: true,
|
||||
}}
|
||||
onClick={handleSetParent}
|
||||
/>
|
||||
{hierarchy.length > 0 && (
|
||||
<div className={styles.hierarchy_container}>
|
||||
<div className={styles.hierarchy_title}>hierarchy</div>
|
||||
{hierarchy.map((item, index) => (
|
||||
<div
|
||||
className={styles.hierarchy_item}
|
||||
key={index}>
|
||||
<span className={styles.hierarchy_name}>{item.name}</span>
|
||||
<span className={styles.hierarchy_slug}>{item.slug}</span>
|
||||
{item?.description && (
|
||||
<span className={styles.hierarchy_description}>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.editor_body} ${styles.aside}`}>
|
||||
<Uploadbox
|
||||
mode="vertical"
|
||||
label="Category Image"
|
||||
uid="category-image"
|
||||
currentImage={media}
|
||||
callback={setImage}
|
||||
onCancel={removeImage}
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
className={styles.input}
|
||||
value={data.slug || ""}
|
||||
labelclassname={styles.label}
|
||||
disabled={slugLock}
|
||||
format={{
|
||||
label: slugLock ? "Unlock" : "Lock",
|
||||
action: (element) => {
|
||||
setTimeout(() => element.focus(), 200);
|
||||
setSlugLock((prev) => !prev);
|
||||
},
|
||||
changeInput: false,
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, slug: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryEditor;
|
||||
@@ -0,0 +1,202 @@
|
||||
.post_title {
|
||||
font-size: 18px;
|
||||
padding: 20px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor_header {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
min-height: 55px;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.editor_status {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
>p {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
|
||||
>span {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
height: 30px;
|
||||
background-color: #3d3d3d !important;
|
||||
border: 1.5px solid #2f2f2f !important;
|
||||
transition: all .2s ease !important;
|
||||
padding: 7px 15px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .5;
|
||||
pointer-events: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.reverse {
|
||||
background-color: #eeeeee !important;
|
||||
color: #000a1e !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #000a1e !important;
|
||||
color: #eeeeee !important;
|
||||
}
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: #552d2d !important;
|
||||
border: 1.5px solid #2c0101 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #e8d6d6 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.danger_reverse {
|
||||
border: 1.5px solid #2c0101 !important;
|
||||
background-color: #e8d6d6 !important;
|
||||
color: #000a1e !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #552d2d !important;
|
||||
color: #eeeeee !important;
|
||||
}
|
||||
}
|
||||
|
||||
.revert {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.editor_content {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor_body {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.aside {
|
||||
width: 200px;
|
||||
min-width: 300px;
|
||||
height: 100%;
|
||||
padding: 15px;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.editor_post {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: transparent !important;
|
||||
border-bottom: none !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 2px -1px #0000001a;
|
||||
|
||||
&:has(input:focus, textarea:focus) {
|
||||
border: 1px solid #509ccf !important;
|
||||
box-shadow: 0 2px 12px -1px #509ccf4b;
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
background-color: #f2f2f2 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
box-shadow: none !important;
|
||||
color: #7c7c7c !important;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hierarchy_container {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hierarchy_item {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background-color: #f4f4f4;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.hierarchy_title {
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
.hierarchy_name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hierarchy_description {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.hierarchy_slug {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
}
|
||||
342
src/components/administrator/EditorMD/editor.markdown.js
Normal file
342
src/components/administrator/EditorMD/editor.markdown.js
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./editor.md.css";
|
||||
|
||||
// css plugin
|
||||
import "./plugin/gallery/gallery.css";
|
||||
|
||||
import { useEditor } from "./provider";
|
||||
import { GalleryPlugin } from "./plugin/gallery/gallery";
|
||||
import { convertMarkdownToJSON } from "./utils/markdown.to.json";
|
||||
import { convertToMarkdown } from "./utils/to.markdown";
|
||||
|
||||
/**
|
||||
* EditorMD
|
||||
* @description
|
||||
* Editor markdown component on change will return markdown and data as json.
|
||||
*
|
||||
* If you want to styling using css module, use className props instead of className.
|
||||
* To use it, wrap to className style you want to change style.
|
||||
*
|
||||
*
|
||||
* Example:
|
||||
* .classname :global(.codex-editor__redactor) {
|
||||
*
|
||||
* padding-bottom: 100px !important;
|
||||
*
|
||||
* }
|
||||
*
|
||||
* @type {React.FC<{
|
||||
* id: string;
|
||||
* data: string;
|
||||
* onChange?: (markdown: string, data: any) => void;
|
||||
* onFocus?: (e: any) => void;
|
||||
* onBlur?: (e: any) => void;
|
||||
* onReady: (editorElement: HTMLElement, editor: any) => void;
|
||||
* className?: string;
|
||||
* config: {
|
||||
* readonly: boolean;
|
||||
* holder: string;
|
||||
* plugin: pluginConfig = { name: config | null }, // header, list, paragraph, quote, code, delimiter, table
|
||||
* tools: {
|
||||
* gallery: {
|
||||
* status: boolean;
|
||||
* trigger: (callback: (data: { url: string; caption: string }) => void) => void;
|
||||
* },
|
||||
* image: {
|
||||
* status: boolean;
|
||||
* upload: (callback: (data: { status: number; file: { url: string } }) => void) => void;
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }>}
|
||||
* @returns
|
||||
*/
|
||||
const EditorMD = (props) => {
|
||||
const editorRef = useRef(null);
|
||||
const onChangeRef = useRef(null);
|
||||
const changeTimeout = useRef(null);
|
||||
const editorFocus = useRef(null);
|
||||
const [editorId, setEditorId] = useState(props.id || "editor-23asd238we23d");
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { editor: registry, registerEditor, unregisterEditor } = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onChange && typeof props.onChange === "function")
|
||||
onChangeRef.current = props.onChange;
|
||||
}, [props.onChange]);
|
||||
|
||||
// listen change data, NOTE: only listen data on blur. not realtime change
|
||||
useEffect(() => {
|
||||
if (!isMounted || !registry?.current?.[editorId] || editorFocus.current)
|
||||
return;
|
||||
const jsonData =
|
||||
typeof props.data === "string"
|
||||
? convertMarkdownToJSON(props.data)
|
||||
: props.data;
|
||||
|
||||
registry.current[editorId].render(jsonData);
|
||||
}, [props.data, registry]);
|
||||
|
||||
const filterData = (data) => {
|
||||
let temp = null;
|
||||
|
||||
// gallery plugin
|
||||
if (data.blocks.length) {
|
||||
temp = {
|
||||
...data,
|
||||
blocks: data.blocks.map((block) =>
|
||||
block.type === "gallery" ? { ...block, type: "image" } : block
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// any filter...
|
||||
|
||||
return temp;
|
||||
};
|
||||
|
||||
const onChangeData = useCallback((data) => {
|
||||
if (!onChangeRef.current) return;
|
||||
|
||||
data = filterData(data);
|
||||
|
||||
const markdown = convertToMarkdown(data);
|
||||
onChangeRef.current(markdown, data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initEditor = async () => {
|
||||
if (!isMounted || registry.current?.[editorId]) return;
|
||||
|
||||
const EditorJS = (await import("@editorjs/editorjs")).default;
|
||||
const Header = (await import("@editorjs/header")).default;
|
||||
const List = (await import("@editorjs/list")).default;
|
||||
const Paragraph = (await import("@editorjs/paragraph")).default;
|
||||
const Quote = (await import("@editorjs/quote")).default;
|
||||
const Code = (await import("@editorjs/code")).default;
|
||||
const Delimiter = (await import("@editorjs/delimiter")).default;
|
||||
const ImageTool = (await import("@editorjs/image")).default;
|
||||
const Table = (await import("@editorjs/table")).default;
|
||||
|
||||
const plugins = {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
placeholder: "Enter a header",
|
||||
levels: [1, 2, 3, 4],
|
||||
defaultLevel: 2,
|
||||
},
|
||||
},
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 3,
|
||||
maxRows: 5,
|
||||
maxCols: 5,
|
||||
},
|
||||
},
|
||||
list: {
|
||||
class: List,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
quote: {
|
||||
class: Quote,
|
||||
inlineToolbar: true,
|
||||
},
|
||||
code: Code,
|
||||
delimiter: Delimiter,
|
||||
};
|
||||
|
||||
const editor = new EditorJS({
|
||||
holder: editorRef.current,
|
||||
placeholder: props?.config?.holder || "Start writing your content",
|
||||
readOnly: props?.config?.readonly || false,
|
||||
tools: {
|
||||
// Gallery, must define status true to enable
|
||||
...(props.config?.tools?.gallery?.status
|
||||
? {
|
||||
gallery: {
|
||||
class: GalleryPlugin,
|
||||
config: {
|
||||
openModal: (callback) => {
|
||||
// Your modal opening logic here
|
||||
// When user selects an image, call:
|
||||
// callback({ url: 'image-url.jpg', caption: 'Optional caption' })
|
||||
// Example:
|
||||
// openYourGalleryModal((selectedImage) => {
|
||||
// callback({
|
||||
// url: selectedImage.url,
|
||||
// caption: selectedImage.title,
|
||||
// });
|
||||
// });
|
||||
|
||||
const galleryConfig = props?.config?.tools?.gallery;
|
||||
|
||||
if (typeof galleryConfig.trigger === "function")
|
||||
galleryConfig.trigger((data) => {
|
||||
callback({
|
||||
url: data.url,
|
||||
caption: data.caption,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Image, must define status true to enable
|
||||
...(props.config?.tools?.image?.status
|
||||
? {
|
||||
image: {
|
||||
class: ImageTool,
|
||||
config: {
|
||||
uploader: {
|
||||
uploadByFile(file) {
|
||||
return new Promise(async (resolve) => {
|
||||
if (
|
||||
typeof props?.config?.tools?.image?.upload ===
|
||||
"function"
|
||||
) {
|
||||
props?.config?.tools?.image?.upload(
|
||||
file,
|
||||
(
|
||||
data = {
|
||||
success: 0,
|
||||
file: { url: "" },
|
||||
error: null,
|
||||
}
|
||||
) => {
|
||||
resolve(data);
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
resolve({
|
||||
success: 1,
|
||||
file: {
|
||||
url: e.target.result,
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
uploadByUrl(url) {
|
||||
return Promise.resolve({
|
||||
success: 1,
|
||||
file: {
|
||||
url: url,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
caption: "optional",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Other plugins, if plugin is defined filter by config
|
||||
...(props.config?.plugin
|
||||
? Object.fromEntries(
|
||||
Object.entries(plugins)
|
||||
.filter(([k, _]) =>
|
||||
Object.keys(props.config.plugin).includes(k)
|
||||
)
|
||||
.map(([k, v]) => [
|
||||
k,
|
||||
{ ...v, ...(props.config.plugin?.[k] || {}) },
|
||||
])
|
||||
)
|
||||
: plugins),
|
||||
},
|
||||
data:
|
||||
typeof props.data === "string"
|
||||
? convertMarkdownToJSON(props.data)
|
||||
: props.data,
|
||||
onChange: (api, event) => {
|
||||
if (changeTimeout.current) clearTimeout(changeTimeout.current);
|
||||
|
||||
changeTimeout.current = setTimeout(async () => {
|
||||
if (!editor || typeof editor.save !== "function") return;
|
||||
|
||||
const data = await editor.save();
|
||||
onChangeData(data);
|
||||
}, 1000);
|
||||
},
|
||||
onReady: () => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (props.onReady && typeof props.onReady === "function")
|
||||
props.onReady(editorRef.current, editor, editorId);
|
||||
|
||||
const editorElement = document.querySelector(".codex-editor");
|
||||
|
||||
// Focus
|
||||
editorElement.addEventListener("focusin", (e) => {
|
||||
if (props.onFocus && typeof props.onFocus === "function")
|
||||
props.onFocus(e);
|
||||
});
|
||||
|
||||
// Blur
|
||||
editorElement.addEventListener("focusout", (e) => {
|
||||
if (props.onBlur && typeof props.onBlur === "function")
|
||||
props.onBlur(e);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registerEditor({ id: editorId, editor, element: editorRef.current });
|
||||
};
|
||||
|
||||
initEditor();
|
||||
|
||||
return () => {
|
||||
unregisterEditor(editorId);
|
||||
};
|
||||
}, [isMounted, registry, editorId]);
|
||||
|
||||
return (
|
||||
<div className={`container ${props.className}`}>
|
||||
{isLoading && (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
Loading your content
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
// className={props.className}
|
||||
onFocus={() => {
|
||||
editorFocus.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
editorFocus.current = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorMD;
|
||||
40
src/components/administrator/EditorMD/editor.md.css
Normal file
40
src/components/administrator/EditorMD/editor.md.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
padding-left: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ce-block__content {
|
||||
max-width: calc(100% - 100px) !important;
|
||||
margin-left: 100px !important;
|
||||
}
|
||||
|
||||
.ce-toolbar__content {
|
||||
max-width: calc(100% - 170px) !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 4px solid rgb(196, 196, 196);
|
||||
border-top: 4px solid #565656;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.modal-gallery-plugin {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #cbd5e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__button:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__button svg {
|
||||
fill: #4a5568;
|
||||
|
||||
path {
|
||||
stroke: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__button span {
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-tool__relatived {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-tool__relatived:hover .modal-gallery-plugin__change {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__change {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 7px 15px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__image-container:hover .modal-gallery-plugin__change {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-gallery-plugin__change:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
244
src/components/administrator/EditorMD/plugin/gallery/gallery.js
Normal file
244
src/components/administrator/EditorMD/plugin/gallery/gallery.js
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Modal Gallery Plugin for Editor.js
|
||||
* Allows inserting images by opening a modal gallery
|
||||
*/
|
||||
export class GalleryPlugin {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: "Gallery Image",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<rect width="14" height="14" x="5" y="5" stroke="currentColor" stroke-width="2" rx="4" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5.13968 15.32L8.69058 11.5661C9.02934 11.2036 9.48873 11 9.96774 11C10.4467 11 10.9061 11.2036 11.2449 11.5661L15.3871 16" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.5806 14.0664L15.0132 12.533C15.3519 12.1705 15.8113 11.9668 16.2903 11.9668C16.7693 11.9668 17.2287 12.1705 17.5675 12.533L18.841 13.9634" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.7778 9.33331H13.7867" />
|
||||
</svg>`,
|
||||
};
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor({ data, api, config, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.config = config || {};
|
||||
|
||||
this.data = {
|
||||
url: data.url || "",
|
||||
file: {
|
||||
url: data.url || "",
|
||||
},
|
||||
caption: data.caption || "",
|
||||
withBorder: data.withBorder !== undefined ? data.withBorder : false,
|
||||
withBackground:
|
||||
data.withBackground !== undefined ? data.withBackground : false,
|
||||
stretched: data.stretched !== undefined ? data.stretched : false,
|
||||
};
|
||||
|
||||
this.wrapper = null;
|
||||
this.imageHolder = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement("div");
|
||||
// this.wrapper.classList.add("modal-gallery-plugin");
|
||||
this.wrapper.classList.add(
|
||||
"cdx-block",
|
||||
"image-tool",
|
||||
"image-tool--caption",
|
||||
"image-tool--filled"
|
||||
);
|
||||
|
||||
if (this.data.url) {
|
||||
this._createImage(this.data.url, this.data.caption);
|
||||
} else {
|
||||
this._createUploadButton();
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
_createUploadButton() {
|
||||
const button = document.createElement("div");
|
||||
button.classList.add("modal-gallery-plugin__button");
|
||||
button.innerHTML = `
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M18 8C18 9.10457 17.1046 10 16 10C14.8954 10 14 9.10457 14 8C14 6.89543 14.8954 6 16 6C17.1046 6 18 6.89543 18 8Z" fill="#1C274C"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12.0574 1.25H11.9426C9.63424 1.24999 7.82519 1.24998 6.41371 1.43975C4.96897 1.63399 3.82895 2.03933 2.93414 2.93414C2.03933 3.82895 1.63399 4.96897 1.43975 6.41371C1.24998 7.82519 1.24999 9.63422 1.25 11.9426V12.0574C1.24999 14.3658 1.24998 16.1748 1.43975 17.5863C1.63399 19.031 2.03933 20.1711 2.93414 21.0659C3.82895 21.9607 4.96897 22.366 6.41371 22.5603C7.82519 22.75 9.63423 22.75 11.9426 22.75H12.0574C14.3658 22.75 16.1748 22.75 17.5863 22.5603C19.031 22.366 20.1711 21.9607 21.0659 21.0659C21.9607 20.1711 22.366 19.031 22.5603 17.5863C22.75 16.1748 22.75 14.3658 22.75 12.0574V11.9426C22.75 9.63423 22.75 7.82519 22.5603 6.41371C22.366 4.96897 21.9607 3.82895 21.0659 2.93414C20.1711 2.03933 19.031 1.63399 17.5863 1.43975C16.1748 1.24998 14.3658 1.24999 12.0574 1.25ZM3.9948 3.9948C4.56445 3.42514 5.33517 3.09825 6.61358 2.92637C7.91356 2.75159 9.62178 2.75 12 2.75C14.3782 2.75 16.0864 2.75159 17.3864 2.92637C18.6648 3.09825 19.4355 3.42514 20.0052 3.9948C20.5749 4.56445 20.9018 5.33517 21.0736 6.61358C21.2484 7.91356 21.25 9.62178 21.25 12C21.25 12.4502 21.2499 12.8764 21.2487 13.2804L21.0266 13.2497C18.1828 12.8559 15.5805 14.3343 14.2554 16.5626C12.5459 12.2376 8.02844 9.28807 2.98073 10.0129L2.75497 10.0454C2.76633 8.63992 2.80368 7.52616 2.92637 6.61358C3.09825 5.33517 3.42514 4.56445 3.9948 3.9948Z" fill="#1C274C"></path> </g></svg>
|
||||
<span>Select image from gallery</span>
|
||||
`;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._openModal();
|
||||
});
|
||||
|
||||
this.wrapper.innerHTML = "";
|
||||
this.wrapper.appendChild(button);
|
||||
}
|
||||
|
||||
_openModal() {
|
||||
// Call the modal function provided in config
|
||||
if (this.config.openModal && typeof this.config.openModal === "function") {
|
||||
this.config.openModal((selectedImage) => {
|
||||
if (selectedImage) {
|
||||
this.data.file.url = selectedImage.url;
|
||||
this.data.url = selectedImage.url;
|
||||
this.data.caption = selectedImage.caption || "";
|
||||
this._createImage(selectedImage.url, selectedImage.caption);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Modal gallery function not provided in config");
|
||||
}
|
||||
}
|
||||
|
||||
_createImage(url, caption) {
|
||||
this.wrapper.innerHTML = "";
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.classList.add("image-tool__image", "image-tool__relatived");
|
||||
|
||||
// preloader
|
||||
const preloader = document.createElement("div");
|
||||
preloader.classList.add("image-tool__image-preloader");
|
||||
container.appendChild(preloader);
|
||||
|
||||
// image
|
||||
const imageTag = document.createElement("img");
|
||||
imageTag.classList.add("image-tool__image-picture");
|
||||
imageTag.src = url;
|
||||
container.appendChild(imageTag);
|
||||
|
||||
// caption
|
||||
const captionElement = document.createElement("div");
|
||||
captionElement.classList.add("cdx-input", "image-tool__caption");
|
||||
captionElement.contentEditable = !this.readOnly;
|
||||
captionElement.innerHTML = caption || "";
|
||||
captionElement.dataset.placeholder = "Caption";
|
||||
|
||||
if (!this.readOnly) {
|
||||
const changeButton = document.createElement("button");
|
||||
changeButton.classList.add("modal-gallery-plugin__change");
|
||||
changeButton.innerHTML = "Change image";
|
||||
changeButton.addEventListener("click", () => {
|
||||
this._openModal();
|
||||
});
|
||||
container.appendChild(changeButton);
|
||||
}
|
||||
|
||||
this.wrapper.appendChild(container);
|
||||
this.wrapper.appendChild(captionElement);
|
||||
}
|
||||
|
||||
save() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings = [
|
||||
{
|
||||
name: "withBorder",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.9919 9.5H19.0015" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.5 5H14.5096" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M14.625 5H15C17.2091 5 19 6.79086 19 9V9.375" />
|
||||
<path stroke="currentColor" stroke-width="2" d="M9.375 5L9 5C6.79086 5 5 6.79086 5 9V9.375" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.3725 5H9.38207" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 9.5H5.00957" />
|
||||
<path stroke="currentColor" stroke-width="2" d="M9.375 19H9C6.79086 19 5 17.2091 5 15V14.625" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.3725 19H9.38207" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 14.55H5.00957" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 13V16M16 19V16M19 16H16M16 16H13" />
|
||||
</svg>
|
||||
`,
|
||||
title: "With border",
|
||||
},
|
||||
{
|
||||
name: "stretched",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9L20 12L17 15" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 12H20" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 9L4 12L7 15" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12H10" />
|
||||
</svg>
|
||||
`,
|
||||
title: "Stretch image",
|
||||
},
|
||||
{
|
||||
name: "withBackground",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 19V19C9.13623 19 8.20435 19 7.46927 18.6955C6.48915 18.2895 5.71046 17.5108 5.30448 16.5307C5 15.7956 5 14.8638 5 13V12C5 9.19108 5 7.78661 5.67412 6.77772C5.96596 6.34096 6.34096 5.96596 6.77772 5.67412C7.78661 5 9.19108 5 12 5H13.5C14.8956 5 15.5933 5 16.1611 5.17224C17.4395 5.56004 18.44 6.56046 18.8278 7.83886C19 8.40666 19 9.10444 19 10.5" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2"
|
||||
d="M16 13V16M16 19V16M19 16H16M16 16H13" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6.5 17.5L17.5 6.5" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M18.9919 10.5H19.0015" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.9919 19H11.0015" />
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13L13 5" />
|
||||
</svg>
|
||||
`,
|
||||
title: "With background",
|
||||
},
|
||||
];
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
const button = document.createElement("div");
|
||||
button.classList.add("ce-popover-item");
|
||||
button.classList.toggle("ce-popover-item--active", this.data[tune.name]);
|
||||
|
||||
// icon
|
||||
const iconcontainer = document.createElement("div");
|
||||
iconcontainer.classList.add(
|
||||
"ce-popover-item__icon",
|
||||
"ce-popover-item__icon--tool"
|
||||
);
|
||||
iconcontainer.innerHTML = tune.icon;
|
||||
button.appendChild(iconcontainer);
|
||||
|
||||
// title
|
||||
const title = document.createElement("div");
|
||||
title.classList.add("ce-popover-item__title");
|
||||
title.innerHTML = tune.title;
|
||||
button.appendChild(title);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("ce-popover-item--active");
|
||||
});
|
||||
|
||||
wrapper.appendChild(button);
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
_toggleTune(tune) {
|
||||
this.data[tune] = !this.data[tune];
|
||||
|
||||
if (this.wrapper) {
|
||||
const container = this.wrapper;
|
||||
|
||||
if (tune === "withBorder") {
|
||||
container.classList.toggle("image-tool--withBorder");
|
||||
}
|
||||
if (tune === "withBackground") {
|
||||
container.classList.toggle("image-tool--withBackground");
|
||||
}
|
||||
if (tune === "stretched") {
|
||||
container.classList.toggle("image-tool--stretched");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate(savedData) {
|
||||
return savedData.url && savedData.url.trim() !== "";
|
||||
}
|
||||
}
|
||||
87
src/components/administrator/EditorMD/provider.js
Normal file
87
src/components/administrator/EditorMD/provider.js
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
const EditorMdContext = createContext();
|
||||
|
||||
export function EditorProvider({ children }) {
|
||||
const editor = useRef({});
|
||||
const element = useRef({});
|
||||
const defaultValue = useRef({
|
||||
list: {
|
||||
blocks: [
|
||||
{
|
||||
type: "list",
|
||||
data: {
|
||||
style: "ordered",
|
||||
meta: {
|
||||
counterType: "numeric",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
content: "",
|
||||
meta: {},
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registerEditor = useCallback(
|
||||
({ id, editor: editorInstance, element: elementInstance }) => {
|
||||
editor.current[id] = editorInstance;
|
||||
element.current[id] = elementInstance;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unregisterEditor = useCallback((id) => {
|
||||
if (!editor.current?.[id]) return;
|
||||
|
||||
const theEditor = editor.current[id];
|
||||
const isEditor = typeof theEditor.destroy === "function";
|
||||
|
||||
delete editor.current[id];
|
||||
delete element.current[id];
|
||||
|
||||
if (isEditor) {
|
||||
theEditor.destroy();
|
||||
}
|
||||
}, []);
|
||||
|
||||
console.log({ editor })
|
||||
|
||||
return (
|
||||
<EditorMdContext.Provider
|
||||
value={{
|
||||
editor,
|
||||
element,
|
||||
defaultValue: defaultValue.current,
|
||||
registerEditor,
|
||||
unregisterEditor,
|
||||
}}>
|
||||
{children}
|
||||
</EditorMdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useEditor = () => {
|
||||
const context = useContext(EditorMdContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!context) {
|
||||
throw new Error("useEditor must be used within a EditorProvider");
|
||||
}
|
||||
}, [context]);
|
||||
|
||||
return context;
|
||||
};
|
||||
7
src/components/administrator/EditorMD/utils/library.js
Normal file
7
src/components/administrator/EditorMD/utils/library.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const waitingFor = (timeout) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, timeout);
|
||||
});
|
||||
};
|
||||
420
src/components/administrator/EditorMD/utils/markdown.to.json.js
Normal file
420
src/components/administrator/EditorMD/utils/markdown.to.json.js
Normal file
@@ -0,0 +1,420 @@
|
||||
const parseInlineFormatting = (text) => {
|
||||
// Parse bold (**text** or __text__)
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
||||
text = text.replace(/__(.+?)__/g, "<b>$1</b>");
|
||||
|
||||
// Parse italic (*text* or _text_) - but not after ** or __
|
||||
text = text.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, "<i>$1</i>");
|
||||
text = text.replace(/(?<!_)_([^_]+?)_(?!_)/g, "<i>$1</i>");
|
||||
|
||||
// Parse strikethrough (~~text~~)
|
||||
text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
||||
|
||||
// Parse code (`code`)
|
||||
text = text.replace(/`(.+?)`/g, "<code class='inline-code'>$1</code>");
|
||||
|
||||
// Parse links [text](url)
|
||||
text = text.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Parse images 
|
||||
text = text.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1">');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const parseTableRow = (row) => {
|
||||
// Remove leading/trailing pipes and split
|
||||
const cells = row
|
||||
.trim()
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((cell) => cell.trim());
|
||||
return cells;
|
||||
};
|
||||
|
||||
const isTableSeparator = (line) => {
|
||||
// Check if line is a table separator (e.g., |---|---|)
|
||||
return /^\|?[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)+\|?$/.test(line);
|
||||
};
|
||||
|
||||
export const convertMarkdownToJSON = (markdownText) => {
|
||||
const lines = markdownText.split("\n");
|
||||
const blocks = [];
|
||||
let currentList = null;
|
||||
let currentListStyle = null;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!line) {
|
||||
// If we were building a list, save it
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables - Check if current line and next line form a table
|
||||
if (line.startsWith("|") && i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1].trim();
|
||||
|
||||
if (isTableSeparator(nextLine)) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
// Parse table header
|
||||
const headers = parseTableRow(line);
|
||||
|
||||
// Skip separator line
|
||||
i += 2;
|
||||
|
||||
// Parse table rows
|
||||
const content = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
||||
const rowData = parseTableRow(lines[i]);
|
||||
content.push(rowData.map((cell) => parseInlineFormatting(cell)));
|
||||
i++;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "table",
|
||||
data: {
|
||||
withHeadings: true,
|
||||
content: [headers.map((h) => parseInlineFormatting(h)), ...content],
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (line.startsWith("#")) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
const level = line.match(/^#+/)[0].length;
|
||||
const text = line.replace(/^#+\s*/, "");
|
||||
blocks.push({
|
||||
type: "header",
|
||||
data: {
|
||||
text: parseInlineFormatting(text),
|
||||
level: Math.min(level, 6),
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered lists (-, *, +)
|
||||
if (line.match(/^[-*+]\s+/)) {
|
||||
const content = line.replace(/^[-*+]\s+/, "");
|
||||
|
||||
if (!currentList || currentListStyle !== "unordered") {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
currentList = [];
|
||||
currentListStyle = "unordered";
|
||||
}
|
||||
|
||||
currentList.push(content);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered lists (1. 2. 3.)
|
||||
if (line.match(/^\d+\.\s+/)) {
|
||||
const content = line.replace(/^\d+\.\s+/, "");
|
||||
|
||||
if (!currentList || currentListStyle !== "ordered") {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
currentList = [];
|
||||
currentListStyle = "ordered";
|
||||
}
|
||||
|
||||
currentList.push(content);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
if (line.startsWith("```")) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
let codeContent = "";
|
||||
const language = line.replace("```", "").trim() || "plaintext";
|
||||
i++;
|
||||
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeContent += lines[i] + "\n";
|
||||
i++;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "code",
|
||||
data: {
|
||||
code: codeContent.trim(),
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith(">")) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
const text = line.replace(/^>\s*/, "");
|
||||
blocks.push({
|
||||
type: "quote",
|
||||
data: {
|
||||
text: parseInlineFormatting(text),
|
||||
caption: "",
|
||||
alignment: "left",
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "delimiter",
|
||||
data: {},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Image standalone ()
|
||||
const imageMatch = line.match(/^!\[(.+?)\]\((.+?)\)$/);
|
||||
if (imageMatch) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "image",
|
||||
data: {
|
||||
file: {
|
||||
url: imageMatch[2],
|
||||
},
|
||||
caption: imageMatch[1],
|
||||
withBorder: false,
|
||||
withBackground: false,
|
||||
stretched: false,
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Link standalone ([text](url))
|
||||
const linkMatch = line.match(/^\[(.+?)\]\((.+?)\)$/);
|
||||
if (linkMatch) {
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "paragraph",
|
||||
data: {
|
||||
text: `<a href="${linkMatch[2]}">${linkMatch[1]}</a>`,
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
currentList = null;
|
||||
currentListStyle = null;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "paragraph",
|
||||
data: {
|
||||
text: parseInlineFormatting(line),
|
||||
},
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
// Handle remaining list
|
||||
if (currentList) {
|
||||
blocks.push({
|
||||
type: "list",
|
||||
data: {
|
||||
style: currentListStyle,
|
||||
meta: {},
|
||||
items: currentList.map((item) => ({
|
||||
content: parseInlineFormatting(item),
|
||||
meta: {},
|
||||
items: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
time: Date.now(),
|
||||
blocks: blocks,
|
||||
version: "2.28.0",
|
||||
};
|
||||
};
|
||||
94
src/components/administrator/EditorMD/utils/to.markdown.js
Normal file
94
src/components/administrator/EditorMD/utils/to.markdown.js
Normal file
@@ -0,0 +1,94 @@
|
||||
export const convertToMarkdown = (editorData) => {
|
||||
let markdown = "";
|
||||
|
||||
if (!editorData) return markdown;
|
||||
|
||||
const convertHtmlToMarkdown = (html) => {
|
||||
let text = html;
|
||||
|
||||
// Convert links: <a href="url">text</a> -> [text](url)
|
||||
text = text.replace(/<a href="([^"]*)"[^>]*>([^<]*)<\/a>/g, "[$2]($1)");
|
||||
|
||||
// Convert bold: <b>text</b> -> **text**
|
||||
text = text.replace(/<b>([^<]*)<\/b>/g, "**$1**");
|
||||
|
||||
// Convert italic: <i>text</i> -> *text*
|
||||
text = text.replace(/<i>([^<]*)<\/i>/g, "*$1*");
|
||||
|
||||
// Convert code: <code>text</code> -> `text`
|
||||
text = text.replace(/<code>([^<]*)<\/code>/g, "`$1`");
|
||||
|
||||
// Convert non-breaking spaces
|
||||
text = text.replace(/ /g, " ");
|
||||
|
||||
// Remove any remaining HTML tags
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
editorData.blocks.forEach((block) => {
|
||||
switch (block.type) {
|
||||
case "header":
|
||||
const level = "#".repeat(block.data.level);
|
||||
const headerText = convertHtmlToMarkdown(block.data.text);
|
||||
markdown += `${level} ${headerText}\n\n`;
|
||||
break;
|
||||
|
||||
case "paragraph":
|
||||
const paragraphText = convertHtmlToMarkdown(block.data.text);
|
||||
markdown += `${paragraphText}\n\n`;
|
||||
break;
|
||||
|
||||
case "list":
|
||||
const renderListItems = (items, depth = 0) => {
|
||||
let listMd = "";
|
||||
items.forEach((item) => {
|
||||
const indent = " ".repeat(depth);
|
||||
const prefix = block.data.style === "ordered" ? "1." : "-";
|
||||
const content = typeof item === "string" ? item : item.content;
|
||||
const convertedContent = convertHtmlToMarkdown(content);
|
||||
listMd += `${indent}${prefix} ${convertedContent}\n`;
|
||||
|
||||
if (item.items && item.items.length > 0) {
|
||||
listMd += renderListItems(item.items, depth + 1);
|
||||
}
|
||||
});
|
||||
return listMd;
|
||||
};
|
||||
|
||||
markdown += renderListItems(block.data.items);
|
||||
markdown += "\n";
|
||||
break;
|
||||
|
||||
case "quote":
|
||||
const quoteText = convertHtmlToMarkdown(block.data.text);
|
||||
markdown += `> ${quoteText}\n`;
|
||||
if (block.data.caption) {
|
||||
const captionText = convertHtmlToMarkdown(block.data.caption);
|
||||
markdown += `> \n> — ${captionText}\n`;
|
||||
}
|
||||
markdown += "\n";
|
||||
break;
|
||||
|
||||
case "code":
|
||||
markdown += `\`\`\`\n${block.data.code}\n\`\`\`\n\n`;
|
||||
break;
|
||||
|
||||
case "delimiter":
|
||||
markdown += `---\n\n`;
|
||||
break;
|
||||
|
||||
case "image":
|
||||
const alt = block.data.caption || "image";
|
||||
markdown += `\n`;
|
||||
markdown += "\n";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return markdown.trim();
|
||||
};
|
||||
634
src/components/administrator/Gallery/gallery.js
Normal file
634
src/components/administrator/Gallery/gallery.js
Normal file
@@ -0,0 +1,634 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import styles from "@/components/administrator/Gallery/gallery.module.css";
|
||||
import Button from "@/components/ui/Button/button";
|
||||
|
||||
import Table from "@/components/administrator/Table/table";
|
||||
import { formatDate, modifyByKeys } from "@/app/library/library";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setNotification } from "@/utils/store/features/ui/notification.slice";
|
||||
import {
|
||||
addMiniWindow,
|
||||
hideMiniWindowBy,
|
||||
hideMiniWindowOnIndex,
|
||||
} from "@/utils/store/features/ui/miniwindow.slice";
|
||||
import SafeImage from "../../ui/SafeImage/safeImage";
|
||||
import Input from "@/components/administrator/form/Input/input";
|
||||
import useGalleryManagement from "@/hooks/useGalleryManagement";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import Loader from "@/components/web/Loader/loader";
|
||||
import Pagination from "@/components/ui/Pagination/pagination";
|
||||
|
||||
// icons
|
||||
import { LuCopy, LuCopyCheck } from "react-icons/lu";
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import { PiWarningDuotone } from "react-icons/pi";
|
||||
import { IoCloseOutline } from "react-icons/io5";
|
||||
import InputFileHidden from "../form/InputFileHidden/input.file.hidden";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import { selectUploading } from "@/utils/store/features/data/upload.slice";
|
||||
import { galleryServices } from "@/app/services/api/gallery.services";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
/**
|
||||
* @type {React.FC <{
|
||||
* initialData?: any[];
|
||||
* onSelected?: Function;
|
||||
* }>}
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const GalleryComponent = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const searchref = useRef(null);
|
||||
|
||||
const [galleryData, setGalleryData] = useState(props.initialData.data);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploaded, setUploaded] = useState([]);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
|
||||
const uploading = useSelector(selectUploading);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
isLoading: loadSearch,
|
||||
isSearch,
|
||||
handleInput,
|
||||
handleSubmit,
|
||||
setInitialData,
|
||||
setSearchFn,
|
||||
setSetDataFn,
|
||||
cancel,
|
||||
} = useSearch();
|
||||
const { deleteconfirmMultipe } = useGalleryManagement();
|
||||
const {
|
||||
addFile,
|
||||
status,
|
||||
isRunning: uploadOnProgress,
|
||||
} = useUploadFile(
|
||||
{ url: "/api/upload" },
|
||||
(data) => {
|
||||
const galleryData = data.files?.[0];
|
||||
|
||||
setGalleryData((prev) => [galleryData, ...prev]);
|
||||
setUploaded((prev) => [...prev, galleryData]);
|
||||
},
|
||||
(error) => console.log({ error })
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const totalProgress = uploading.reduce(
|
||||
(acc, item) => acc + item.progress,
|
||||
0
|
||||
);
|
||||
return totalProgress / uploading.length;
|
||||
}, [uploading]);
|
||||
|
||||
const galleries = useMemo(() => {
|
||||
return galleryData.map((media) =>
|
||||
modifyByKeys(media, ["createdAt"], (date) => formatDate(date, true))
|
||||
);
|
||||
}, [galleryData]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(component, data) => {
|
||||
const id = "gallery_item_editing";
|
||||
dispatch(hideMiniWindowBy({ role: "id", value: id }));
|
||||
|
||||
dispatch(
|
||||
addMiniWindow({
|
||||
status: true,
|
||||
component,
|
||||
data,
|
||||
id,
|
||||
onUpdate: (result) => {
|
||||
setGalleryData((prev) =>
|
||||
prev.map((data) => (data._id === result._id ? result : data))
|
||||
);
|
||||
},
|
||||
onDelete: (result) => {
|
||||
setGalleryData((prev) =>
|
||||
prev.filter((data) => data._id !== result._id)
|
||||
);
|
||||
setSelected((prev) =>
|
||||
prev.filter((data) => data._id !== result._id)
|
||||
);
|
||||
},
|
||||
height: "fit-content",
|
||||
...(props.onSelected && {
|
||||
onSelect: (result) => {
|
||||
props.onSelected(result);
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, props]
|
||||
);
|
||||
|
||||
const getSortedData = useCallback(
|
||||
async ({ page, limit }, sort) => {
|
||||
try {
|
||||
const data = await galleryServices.getGallery({ page, limit }, sort);
|
||||
|
||||
setGalleryData(data.data);
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
setNotification({
|
||||
status: true,
|
||||
type: "error",
|
||||
message: "Getting sorted data failed! Contact Administrator.",
|
||||
timeout: 3000,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loadSearch);
|
||||
}, [loadSearch]);
|
||||
|
||||
return (
|
||||
<div className={styles.gallery_container}>
|
||||
<div className={styles.gallery_header}>
|
||||
<div className={styles.header_wrapper}>
|
||||
<h1>Gallery Management</h1>
|
||||
<div className={styles.gallery_action}>
|
||||
<div className={styles.gallery_status}>
|
||||
{Number.isInteger(parseInt(progress)) && (
|
||||
<p>
|
||||
Uploading:{" "}
|
||||
<span className={styles.action}>
|
||||
{uploadingCount} file(s) - {progress.toFixed(2)}%
|
||||
</span>{" "}
|
||||
</p>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
Selected:{" "}
|
||||
<span>
|
||||
{selected.length} item{selected.length > 1 && "s"}
|
||||
</span>{" "}
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className={styles.action}
|
||||
onClick={() => {
|
||||
handleEdit(ItemGalleryMultipleEdit, selected);
|
||||
}}>
|
||||
Edit
|
||||
</span>{" "}
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className={styles.action}
|
||||
onClick={() =>
|
||||
deleteconfirmMultipe(selected, (result) => {
|
||||
const deletedIds = result.map((data) => data._id);
|
||||
// if all data is deleted
|
||||
if (galleryData.length === deletedIds.length)
|
||||
window.location.reload();
|
||||
|
||||
setSelected([]);
|
||||
|
||||
return setGalleryData((prev) =>
|
||||
prev.filter(
|
||||
(data) => !deletedIds.includes(data._id)
|
||||
)
|
||||
);
|
||||
})
|
||||
}>
|
||||
Delete
|
||||
</span>{" "}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<InputFileHidden
|
||||
onChange={(files) => {
|
||||
setUploadingCount(files.length);
|
||||
addFile(files);
|
||||
}}>
|
||||
{(_, onClick) => {
|
||||
return (
|
||||
<Button
|
||||
className={styles.gallery_button}
|
||||
disabled={Number.isInteger(progress) && progress < 100}
|
||||
onClick={onClick}>
|
||||
{uploadOnProgress && <Loader color="#fff" />}
|
||||
Upload Images
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</InputFileHidden>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.header_wrapper}>
|
||||
<form
|
||||
className={styles.search_container}
|
||||
onSubmit={handleSubmit}>
|
||||
<FiSearch />
|
||||
<input
|
||||
type="text"
|
||||
ref={searchref}
|
||||
placeholder="Search Gallery"
|
||||
onInput={handleInput}
|
||||
defaultValue={props.initialData?.keyword || ""}
|
||||
onFocus={() => {
|
||||
if (!isSearch) setInitialData(galleryData);
|
||||
|
||||
setSearchFn(async (keyword) => {
|
||||
const result = await galleryServices.getGallery(
|
||||
{ page: 1, limit: 100 },
|
||||
{ createdAt: -1 },
|
||||
keyword
|
||||
);
|
||||
|
||||
return result.data;
|
||||
});
|
||||
setSetDataFn((data) => {
|
||||
setGalleryData(data);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{isSearch && (
|
||||
<IoCloseOutline
|
||||
onClick={() => {
|
||||
searchref.current.value = "";
|
||||
cancel();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={styles.loader_container}>
|
||||
<div className={styles.loader}>
|
||||
<Loader
|
||||
color="#000"
|
||||
width={30}
|
||||
/>
|
||||
<p>Getting data</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={galleries}
|
||||
role={{
|
||||
preview: "Image",
|
||||
title: "Title",
|
||||
alt: "Alt",
|
||||
filename: "Filename",
|
||||
url: "URL",
|
||||
createdAt: "Date",
|
||||
}}
|
||||
mediaPreview={{
|
||||
preview: "url",
|
||||
}}
|
||||
sortRole={{
|
||||
canSorting: true,
|
||||
excludeSorting: ["preview", "url"],
|
||||
sortingFn: getSortedData,
|
||||
}}
|
||||
onClick={(data) => {
|
||||
handleEdit(ItemGalleryEdit, data);
|
||||
}}
|
||||
onSelected={(data) => {
|
||||
setSelected(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSearch && (
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
totalPage={props.initialData.totalPage}
|
||||
currentPage={(() => {
|
||||
const page = parseInt(
|
||||
Object.fromEntries(searchParams.entries())?.page
|
||||
);
|
||||
return page || props.initialData.page;
|
||||
})()}
|
||||
getData={galleryServices.getGallery}
|
||||
getIsLoading={(status) => setIsLoading(status)}
|
||||
setData={(data) => setGalleryData(data.data)}
|
||||
limit={50}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {React.FC<{
|
||||
* data: any[];
|
||||
* onUpdate: Function;
|
||||
* onDelete: Function;
|
||||
* onSelect: Function;
|
||||
* }>}
|
||||
* @returns
|
||||
*/
|
||||
export const ItemGalleryMultipleEdit = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [data, setData] = useState(props.data);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const currentData = useMemo(() => data[currentIndex], [currentIndex, data]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const current = data.filter((_, index) => index !== currentIndex);
|
||||
if (current.length === 0) {
|
||||
dispatch(hideMiniWindowBy({ role: "id", value: "gallery_item_editing" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setData(current);
|
||||
}, [data, currentIndex, dispatch]);
|
||||
|
||||
return (
|
||||
<div className={styles.item_multiple_edit}>
|
||||
<div className={styles.item_navigation}>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.navigation_item} ${
|
||||
index === currentIndex && styles.active
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}>
|
||||
<SafeImage
|
||||
src={item.url}
|
||||
alt={item.title}
|
||||
className={styles.navigation_image}
|
||||
width={100}
|
||||
height={100}
|
||||
styles={{ width: "auto", height: "auto" }}
|
||||
fallbackSrc="https://placehold.co/300x200/f0f0f0/999/png?text=NotFound"
|
||||
/>
|
||||
<p>{item.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ItemGalleryEdit
|
||||
data={currentData}
|
||||
onUpdate={(data) => {
|
||||
setData((prev) =>
|
||||
prev.map((item, index) => (index === currentIndex ? data : item))
|
||||
);
|
||||
props.onUpdate(data);
|
||||
}}
|
||||
onDelete={(data) => {
|
||||
handleDelete();
|
||||
setTimeout(() => props.onDelete(data));
|
||||
}}
|
||||
onSelect={props.onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {React.FC<{
|
||||
* data: any;
|
||||
* onUpdate: Function;
|
||||
* onDelete: Function;
|
||||
* onSelect: Function;
|
||||
* }>}
|
||||
* @returns
|
||||
*/
|
||||
export const ItemGalleryEdit = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [data, setData] = useState(props.data);
|
||||
const [isUpdated, setIsUpdated] = useState(false);
|
||||
const [mediaAvailable, setMediaAvailable] = useState(true);
|
||||
const [copied, setCopied] = useState({
|
||||
filename: false,
|
||||
url: false,
|
||||
});
|
||||
const { update, deleteconfirm } = useGalleryManagement();
|
||||
|
||||
const isChanged = useMemo(() => {
|
||||
return JSON.stringify(data) !== JSON.stringify(props.data);
|
||||
}, [data, props.data]);
|
||||
const isValidData = useMemo(() => {
|
||||
const role = ["title", "alt", "url"];
|
||||
return {
|
||||
status: role.every((v) => data[v]) || role.every((v) => props.data[v]),
|
||||
key: role.find((v) => !data[v]) || role.find((v) => !props.data[v]),
|
||||
};
|
||||
}, [props.data, data]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
const currentData = modifyByKeys(
|
||||
data,
|
||||
["url"],
|
||||
(url) => `/media${url.split("media")[1]}`
|
||||
);
|
||||
await update(currentData, (result) => {
|
||||
if (props.onUpdate && typeof props.onUpdate === "function")
|
||||
props.onUpdate(result);
|
||||
setData(result);
|
||||
setIsUpdated(true);
|
||||
});
|
||||
}, [data, props, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.values(copied).some((v) => v))
|
||||
setTimeout(() => {
|
||||
setCopied({ filename: false, url: false });
|
||||
}, 3000);
|
||||
}, [copied]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(props.data);
|
||||
}, [props.data]);
|
||||
|
||||
return (
|
||||
<div className={styles.item_container}>
|
||||
<div className={styles.item_header}>
|
||||
<h1>{props.data?.title}</h1>
|
||||
</div>
|
||||
<div className={styles.item_action}>
|
||||
<div className={styles.item_info}>
|
||||
<p>
|
||||
ID: <span>{props.data?._id}</span>
|
||||
</p>
|
||||
<p>
|
||||
Uploaded At: <span>{props.data?.createdAt}</span>
|
||||
</p>
|
||||
{isChanged && mediaAvailable && (
|
||||
<p>
|
||||
status: <span>Changed</span>
|
||||
</p>
|
||||
)}
|
||||
{!mediaAvailable && props.onSelect && (
|
||||
<p>
|
||||
Error: <span>You Can`t Select This Media</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.item_action_button}>
|
||||
{props.onSelect &&
|
||||
typeof props.onSelect === "function" &&
|
||||
mediaAvailable && (
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
if (props.onSelect && typeof props.onSelect === "function")
|
||||
props.onSelect(data);
|
||||
}}
|
||||
disabled={!isValidData.status || (!isUpdated && isChanged)}>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
{isChanged && (
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={async () => {
|
||||
await handleUpdate();
|
||||
}}>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={async () =>
|
||||
deleteconfirm(props.data, (result) => {
|
||||
props.onDelete(result);
|
||||
dispatch(hideMiniWindowOnIndex(props.index));
|
||||
})
|
||||
}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.item_body}>
|
||||
<div className={styles.body_image}>
|
||||
<SafeImage
|
||||
src={props.data?.url}
|
||||
alt={props.data?.alt || props.data?.title}
|
||||
className={styles.image}
|
||||
width={500}
|
||||
height={500}
|
||||
onClick={() => {
|
||||
window.open(props.data?.url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
getIsError={(error) => {
|
||||
console.log({ error });
|
||||
setMediaAvailable(!error);
|
||||
}}
|
||||
fallbackSrc="https://placehold.co/300x200/f0f0f0/999/png?text=NotFound"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body_content}>
|
||||
<div className={styles.item_box}>
|
||||
<p className={`${styles.label} ${styles.item_box_label}`}>
|
||||
URL{" "}
|
||||
<span>
|
||||
{copied.url ? (
|
||||
<LuCopyCheck />
|
||||
) : (
|
||||
<LuCopy
|
||||
onClick={() => {
|
||||
window.navigator.clipboard.writeText(props.data?.url);
|
||||
dispatch(
|
||||
setNotification({
|
||||
status: true,
|
||||
message: "URL Copied to clipboard",
|
||||
timeout: 3000,
|
||||
})
|
||||
);
|
||||
setCopied((prev) => ({ ...prev, url: true }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={styles.item_box_value}
|
||||
onClick={() => {
|
||||
window.open(props.data?.url, "_blank", "noopener,noreferrer");
|
||||
}}>
|
||||
{props.data?.url}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.item_box}>
|
||||
<p className={`${styles.label} ${styles.item_box_label}`}>
|
||||
Filename{" "}
|
||||
<span>
|
||||
{copied.filename ? (
|
||||
<LuCopyCheck />
|
||||
) : (
|
||||
<LuCopy
|
||||
onClick={() => {
|
||||
window.navigator.clipboard.writeText(
|
||||
props.data?.filename
|
||||
);
|
||||
dispatch(
|
||||
setNotification({
|
||||
status: true,
|
||||
message: "Filename Copied to clipboard",
|
||||
timeout: 3000,
|
||||
})
|
||||
);
|
||||
setCopied((prev) => ({ ...prev, filename: true }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p className={styles.item_box_value}>{props.data?.filename}</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Title"
|
||||
value={data?.title || ""}
|
||||
className={styles.input}
|
||||
labelclassname={styles.label}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Alt"
|
||||
value={data.alt || ""}
|
||||
className={styles.input}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, alt: e.target.value }))
|
||||
}
|
||||
labelclassname={styles.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isValidData.status && (
|
||||
<div className={styles.warning}>
|
||||
<p className={styles.warning_text}>
|
||||
<span>
|
||||
<PiWarningDuotone /> Warning:
|
||||
</span>
|
||||
{isValidData.key} is missing from your data. Please update your
|
||||
data!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Gallery = memo(GalleryComponent);
|
||||
export default Gallery;
|
||||
344
src/components/administrator/Gallery/gallery.module.css
Normal file
344
src/components/administrator/Gallery/gallery.module.css
Normal file
@@ -0,0 +1,344 @@
|
||||
.gallery_container {
|
||||
height: fit-content;
|
||||
/* min-height: 100vh; */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.gallery_header {
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #f5f7fa;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.header_wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
>h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery_action {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery_status {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery_button {
|
||||
/* padding: 7px 15px !important; */
|
||||
/* background-color: #3d3d3d !important; */
|
||||
font-size: 13px !important;
|
||||
|
||||
&:hover {
|
||||
/* background-color: #000 !important; */
|
||||
}
|
||||
}
|
||||
|
||||
.search_container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
background-color: #e8e8e8;
|
||||
padding: 15px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 18px;
|
||||
|
||||
>input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loader_container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 230px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
>p {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.item_multiple_edit {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item_navigation {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
margin-top: 56px;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.navigation_item {
|
||||
height: fit-content;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: all .1s ease;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: #bdbdbd !important;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation_image {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.item_container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.item_header {
|
||||
padding: 0 20px;
|
||||
|
||||
>h1 {
|
||||
width: 90%;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.item_action {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.item_info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.item_action_button {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 7px 20px !important;
|
||||
font-size: 13px !important;
|
||||
background-color: #3d3d3d !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.item_body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 30px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.body_image {
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
height: 230px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.image {
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.body_content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: transparent !important;
|
||||
border-bottom: none !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 2px -1px #0000001a;
|
||||
|
||||
&:has(input:focus, textarea:focus) {
|
||||
border: 1px solid #509ccf !important;
|
||||
box-shadow: 0 2px 12px -1px #509ccf4b;
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
background-color: #f2f2f2 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
box-shadow: none !important;
|
||||
color: #7c7c7c !important;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item_box_label {
|
||||
padding-left: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
padding-left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.item_box_value {
|
||||
font-size: 13px;
|
||||
padding: 5px;
|
||||
padding-left: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.warning_text {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background-color: #eed7d7;
|
||||
padding: 5px 10px;
|
||||
color: #d80000;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
307
src/components/administrator/JoinBridge/join.bridge.js
Normal file
307
src/components/administrator/JoinBridge/join.bridge.js
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import styles from "@/components/administrator/JoinBridge/joinbridge.module.css";
|
||||
|
||||
import { useSettingManagement } from "@/hooks/useSettingManagement";
|
||||
import { normalizeURL } from "@/app/library/library";
|
||||
|
||||
import Input from "@/components/administrator/form/Input/input";
|
||||
import Button from "@/components/ui/Button/button";
|
||||
import Table from "../Table/table";
|
||||
import Loader from "@/components/web/Loader/loader";
|
||||
import Switch from "@/components/ui/Switch/switch";
|
||||
|
||||
import { TbArrowFork, TbBrandSpeedtest } from "react-icons/tb";
|
||||
import { MdDeleteForever } from "react-icons/md";
|
||||
import { PiWarningDuotone } from "react-icons/pi";
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {React.FC<{
|
||||
* bridgeSettings: any;
|
||||
* status: boolean;
|
||||
* ID: string;
|
||||
* }>}
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const JoinBridge = (props) => {
|
||||
const [endpoint, setEndpoint] = useState({
|
||||
name: "",
|
||||
url: "",
|
||||
status: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState({
|
||||
addData: false,
|
||||
action: false,
|
||||
auth: false,
|
||||
});
|
||||
const [isauthorized, setIsAuthorized] = useState(false);
|
||||
const [data, setData] = useState(props?.bridgeSettings?.endpoints || []);
|
||||
const { testBridgeUrl, updateEndpoints, getAuthorization } =
|
||||
useSettingManagement();
|
||||
|
||||
// action
|
||||
const testUrl = useCallback(
|
||||
(url, api_key) => {
|
||||
if (isLoading.action) return;
|
||||
|
||||
let newData;
|
||||
setIsLoading((prev) => ({ ...prev, action: true }));
|
||||
testBridgeUrl(
|
||||
{ url, api_key },
|
||||
async (result) => {
|
||||
setData((prev) => {
|
||||
newData = prev.map((item) =>
|
||||
item.url === url ? { ...item, status: true } : item
|
||||
);
|
||||
return newData;
|
||||
});
|
||||
await updateEndpoints(newData);
|
||||
},
|
||||
async () => {
|
||||
setData((prev) => {
|
||||
newData = prev.map((item) =>
|
||||
item.url === url ? { ...item, status: false } : item
|
||||
);
|
||||
|
||||
return newData;
|
||||
});
|
||||
await updateEndpoints(newData);
|
||||
},
|
||||
async () => {
|
||||
setIsLoading((prev) => ({ ...prev, action: false }));
|
||||
}
|
||||
);
|
||||
},
|
||||
[testBridgeUrl, updateEndpoints, isLoading]
|
||||
);
|
||||
const deleteUrl = useCallback(
|
||||
(url) => {
|
||||
if (isLoading.action) return;
|
||||
setIsLoading((prev) => ({ ...prev, action: true }));
|
||||
|
||||
const newData = data.filter((item) => item.url !== url);
|
||||
|
||||
updateEndpoints(
|
||||
newData,
|
||||
() => {
|
||||
setData(newData);
|
||||
},
|
||||
null,
|
||||
() => {
|
||||
setIsLoading((prev) => ({ ...prev, action: false }));
|
||||
}
|
||||
);
|
||||
},
|
||||
[data, isLoading, updateEndpoints]
|
||||
);
|
||||
const updateStatus = useCallback(
|
||||
(url, status) => {
|
||||
if (isLoading.action) return;
|
||||
setIsLoading((prev) => ({ ...prev, action: true }));
|
||||
|
||||
const newData = data.map((item) =>
|
||||
item.url === url ? { ...item, status: status } : item
|
||||
);
|
||||
|
||||
updateEndpoints(
|
||||
newData,
|
||||
() => {
|
||||
setData(newData);
|
||||
},
|
||||
null,
|
||||
() => {
|
||||
setIsLoading((prev) => ({ ...prev, action: false }));
|
||||
}
|
||||
);
|
||||
},
|
||||
[data, isLoading, updateEndpoints]
|
||||
);
|
||||
const authorization = useCallback(async () => {
|
||||
if (isLoading.auth) return;
|
||||
setIsLoading((prev) => ({ ...prev, auth: true }));
|
||||
await getAuthorization(
|
||||
{
|
||||
url: endpoint.url,
|
||||
ID: props.ID,
|
||||
},
|
||||
(data) => {
|
||||
setEndpoint((prev) => ({ ...prev, ...data, status: true }));
|
||||
setIsAuthorized(true);
|
||||
},
|
||||
null,
|
||||
() => {
|
||||
setIsLoading((prev) => ({ ...prev, auth: false }));
|
||||
}
|
||||
);
|
||||
}, [props, endpoint, isLoading, getAuthorization]);
|
||||
|
||||
const displayData = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
status: (
|
||||
<Switch
|
||||
value={item.status}
|
||||
disabled={isLoading.action}
|
||||
onChange={(value) => {
|
||||
updateStatus(item.url, value);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
action: (
|
||||
<span className={styles.actions}>
|
||||
<MdDeleteForever
|
||||
title="Delete Endpoint"
|
||||
style={{ opacity: isLoading.action ? "0.5" : "1" }}
|
||||
size={20}
|
||||
className={`${styles.action_icon} ${styles.danger}`}
|
||||
onClick={() => deleteUrl(item.url)}
|
||||
/>
|
||||
<TbBrandSpeedtest
|
||||
title="Test Endpoint"
|
||||
size={20}
|
||||
style={{ opacity: isLoading.action ? "0.5" : "1" }}
|
||||
className={`${styles.action_icon} ${styles.success}`}
|
||||
onClick={() => testUrl(item.url, item.API_KEY)}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[data, isLoading, testUrl, deleteUrl, updateStatus]
|
||||
);
|
||||
const isValid = useMemo(() => {
|
||||
return (
|
||||
(data.some((item) => item.url !== endpoint.url) || data.length === 0) &&
|
||||
Object.values(endpoint).every((item) => item)
|
||||
);
|
||||
}, [endpoint, data]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.header_title}>
|
||||
<h3>
|
||||
<TbArrowFork /> Join Bridge
|
||||
</h3>
|
||||
<p>
|
||||
{props.status ? (
|
||||
"You can manage content across the company and define API endpoints for data exchange"
|
||||
) : (
|
||||
<span className={styles.warning}>
|
||||
<PiWarningDuotone /> Generate CMS ID first on{" "}
|
||||
<Link href={"/dashboard/settings/general"}>
|
||||
General Settings
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={styles.content}
|
||||
style={props.status ? {} : { opacity: "0.5", pointerEvents: "none" }}>
|
||||
<div className={styles.input_container}>
|
||||
<div className={styles.input_content}>
|
||||
<Input
|
||||
className={styles.input}
|
||||
label="Name (alias)"
|
||||
value={endpoint?.name}
|
||||
onChange={(e) =>
|
||||
setEndpoint({ ...endpoint, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className={styles.input}
|
||||
label="URL"
|
||||
value={endpoint?.url}
|
||||
onChange={(e) => {
|
||||
setEndpoint({
|
||||
...endpoint,
|
||||
url: e.target.value,
|
||||
API_KEY: "",
|
||||
status: false,
|
||||
});
|
||||
setIsAuthorized(false);
|
||||
}}
|
||||
error={{
|
||||
status: endpoint.url && !isauthorized,
|
||||
message: "This url is not Authorized. Please authorize first.",
|
||||
}}
|
||||
continouslyFrom={
|
||||
endpoint.url.startsWith("http") ? "" : "https://"
|
||||
}
|
||||
/>
|
||||
<div className={styles.action_horizontal}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={
|
||||
!endpoint.url ||
|
||||
data.some((item) =>
|
||||
new RegExp(normalizeURL(endpoint.url)).test(
|
||||
normalizeURL(item.url)
|
||||
)
|
||||
) ||
|
||||
isauthorized
|
||||
}
|
||||
onClick={() => authorization()}>
|
||||
{isLoading.auth && <Loader color="#fff" />}
|
||||
Authorization
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!isValid || isLoading.addData}
|
||||
onClick={() => {
|
||||
const sentData = [...data, endpoint];
|
||||
|
||||
setIsLoading({ ...isLoading, addData: true });
|
||||
updateEndpoints(
|
||||
sentData,
|
||||
(data) => {
|
||||
setData(data.endpoints);
|
||||
setEndpoint({
|
||||
name: "",
|
||||
url: "",
|
||||
status: false,
|
||||
API_KEY: "",
|
||||
});
|
||||
},
|
||||
null,
|
||||
() => {
|
||||
setIsLoading({ ...isLoading, addData: false });
|
||||
}
|
||||
);
|
||||
}}>
|
||||
{isLoading.addData && <Loader color="#fff" />}
|
||||
Add Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.separator}></span>
|
||||
<Table
|
||||
role={{
|
||||
name: "Name",
|
||||
url: "URL",
|
||||
status: "Status",
|
||||
action: "Action",
|
||||
}}
|
||||
style={{
|
||||
action: {
|
||||
minWidth: "30px !important",
|
||||
maxWidth: "30px !important",
|
||||
},
|
||||
}}
|
||||
data={displayData}
|
||||
showEmptyStatus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JoinBridge;
|
||||
135
src/components/administrator/JoinBridge/joinbridge.module.css
Normal file
135
src/components/administrator/JoinBridge/joinbridge.module.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.input {
|
||||
background-color: #fff !important;
|
||||
border-bottom: none !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 2px -1px #0000001a;
|
||||
|
||||
&:has(input:focus, textarea:focus) {
|
||||
border: 1px solid #509ccf !important;
|
||||
box-shadow: 0 2px 12px -1px #509ccf4b;
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
background-color: #f2f2f2 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
box-shadow: none !important;
|
||||
color: #7c7c7c !important;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 2px -1px #0000001a;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.header_title {
|
||||
>h3 {
|
||||
font-size: 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
>p {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header_action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action_horizontal {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 13px !important;
|
||||
border: 1.5px solid transparent !important;
|
||||
|
||||
&.reverse {
|
||||
border: 1.5px solid #4085b3 !important;
|
||||
background-color: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input_container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input_content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 10px 0;
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action_icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50px;
|
||||
color: #565656;
|
||||
cursor: pointer;
|
||||
|
||||
&.danger:hover {
|
||||
color: #c54b4e;
|
||||
}
|
||||
|
||||
&.success:hover {
|
||||
color: #4085b3;
|
||||
}
|
||||
}
|
||||
305
src/components/administrator/LoginForm/login.form.js
Normal file
305
src/components/administrator/LoginForm/login.form.js
Normal file
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import styles from "@/components/administrator/LoginForm/loginform.module.css";
|
||||
|
||||
import { IoCloseOutline } from "react-icons/io5";
|
||||
import Input from "@/components/administrator/form/Input/input";
|
||||
import Loader from "../../web/Loader/loader";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setNotification } from "@/utils/store/features/ui/notification.slice";
|
||||
import { login } from "@/services/auth";
|
||||
// import { description } from "@/config/default";
|
||||
import axios from "axios";
|
||||
import useLogin from "@/hooks/useLogin";
|
||||
import { LiaLockSolid, LiaUserTieSolid } from "react-icons/lia";
|
||||
|
||||
const LoginForm = ({ description }) => {
|
||||
const dispatch = useDispatch();
|
||||
const form = useRef({});
|
||||
const [notif, setNotif] = useState({
|
||||
status: false,
|
||||
message: "Error",
|
||||
type: "error",
|
||||
});
|
||||
const [userDetails, setUserDetails] = useState({});
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
return ["username", "password"].some((key) => !userDetails[key]);
|
||||
}, [userDetails]);
|
||||
const [error, setError] = useState({});
|
||||
const [isAllowedGeoLocation, setIsAllowedGeoLocation] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { requestGeoLocation } = useLogin();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectUrl = searchParams.get("redirect") || null;
|
||||
const reason = searchParams.get("reason") || null;
|
||||
|
||||
const getCityFromCoords = useCallback(async (lat, lon) => {
|
||||
const res = await axios.get(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.current.username) return;
|
||||
|
||||
form.current.username.focus();
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reason && !redirectUrl) return;
|
||||
|
||||
dispatch(
|
||||
setNotification({
|
||||
status: true,
|
||||
message:
|
||||
reason === "unauthorized"
|
||||
? "You are logged out! Please login again 🤣"
|
||||
: "Unexpected Error",
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
}, [redirectUrl, reason, dispatch]);
|
||||
|
||||
const handleGeolocation = useCallback(
|
||||
(detail) => {
|
||||
if (!navigator.geolocation) {
|
||||
// Browser does not support Geolocation
|
||||
setIsAllowedGeoLocation(true);
|
||||
return detail;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let detailData = detail;
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
detailData.latitude = position.coords.latitude;
|
||||
detailData.longitude = position.coords.longitude;
|
||||
|
||||
const response = await getCityFromCoords(
|
||||
detailData.latitude,
|
||||
detailData.longitude
|
||||
);
|
||||
|
||||
if (response) {
|
||||
detailData = {
|
||||
...detailData,
|
||||
display_name: response.display_name,
|
||||
village: response.address.village,
|
||||
municipality: response.address.municipality,
|
||||
city: response.address.county,
|
||||
region: response.address.state,
|
||||
};
|
||||
}
|
||||
|
||||
setIsAllowedGeoLocation(true);
|
||||
resolve(detailData);
|
||||
},
|
||||
(error) => {
|
||||
setIsAllowedGeoLocation(false);
|
||||
resolve(null);
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
[getCityFromCoords]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAllowedGeoLocation) return;
|
||||
|
||||
requestGeoLocation({
|
||||
action: async () => {
|
||||
const result = await handleGeolocation(userDetails?.detail);
|
||||
return result;
|
||||
},
|
||||
callback: (data) => {
|
||||
setUserDetails((prev) => ({ ...prev, detail: data }));
|
||||
},
|
||||
});
|
||||
}, [
|
||||
isAllowedGeoLocation,
|
||||
requestGeoLocation,
|
||||
handleGeolocation,
|
||||
userDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const getDetail = async () => {
|
||||
try {
|
||||
const response = await axios.get("https://ipapi.co/json/");
|
||||
let detailData = {
|
||||
ip: response.data.ip,
|
||||
city: response.data.city,
|
||||
region: response.data.region,
|
||||
country: response.data.country_name,
|
||||
timezone: response.data.timezone,
|
||||
org: response.data.org,
|
||||
};
|
||||
|
||||
const geolocation = await handleGeolocation(detailData);
|
||||
if (geolocation) {
|
||||
setUserDetails((prev) => ({ ...prev, detail: geolocation }));
|
||||
return;
|
||||
}
|
||||
|
||||
setUserDetails((prev) => ({ ...prev, detail: detailData }));
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
setUserDetails((prev) => ({ ...prev, detail: { error: true } }));
|
||||
}
|
||||
};
|
||||
|
||||
getDetail();
|
||||
}, [getCityFromCoords, handleGeolocation]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
// state section
|
||||
setError({});
|
||||
setNotif((prev) => ({ ...prev, status: false }));
|
||||
setIsLoading(true);
|
||||
|
||||
const data = new FormData(e.target);
|
||||
data.append("detail", JSON.stringify(userDetails?.detail || {}));
|
||||
// login section
|
||||
const result = await login(data);
|
||||
|
||||
console.log({ result });
|
||||
|
||||
if (!result.status) {
|
||||
// on production build, client side can't access the error message from server action
|
||||
// so we have to manually check the error message
|
||||
const errorMessage = {
|
||||
username:
|
||||
"Username not found, please try again with another username",
|
||||
password:
|
||||
"Password is incorrect, please type in the correct password",
|
||||
};
|
||||
|
||||
if (["username", "password"].includes(result.message)) {
|
||||
setError({
|
||||
message: errorMessage[result.message],
|
||||
error: result.message,
|
||||
});
|
||||
form.current?.[result.message].focus();
|
||||
}
|
||||
|
||||
setNotif({
|
||||
status: true,
|
||||
message: (() => {
|
||||
if (["username", "password"].includes(result.message))
|
||||
return "Credential is incorrect, please try again with another credential.";
|
||||
|
||||
if (result.message === "disabled")
|
||||
return "Your account is disabled, please contact your administrator.";
|
||||
|
||||
return "Unexpected Error";
|
||||
})(),
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setNotif({
|
||||
status: true,
|
||||
message: "Login Success, redirecting...",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
window.location.href = redirectUrl || "/dashboard";
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[redirectUrl, form, userDetails]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* notif scope */}
|
||||
{notif.status && (
|
||||
<div className={`${styles.notification} ${styles[notif.type]}`}>
|
||||
<p className={styles.notif_message}></p>
|
||||
{notif.message}
|
||||
<button
|
||||
onClick={() => setNotif((prev) => ({ ...prev, status: false }))}>
|
||||
<IoCloseOutline />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form scope */}
|
||||
<div className={styles.form_login}>
|
||||
<div className={styles.form_header}>
|
||||
<h1>Welcome Back!</h1>
|
||||
<p className={styles.description}>
|
||||
<span>{description.title} </span>— Web Admin
|
||||
</p>
|
||||
<p>Login to your account. Enter your details below!</p>
|
||||
</div>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={onSubmit}>
|
||||
<Input
|
||||
label="Username"
|
||||
name="username"
|
||||
icon={<LiaUserTieSolid />}
|
||||
ref={(input) => (form.current.username = input)}
|
||||
error={{
|
||||
status: error.error === "username",
|
||||
message: error.message,
|
||||
}}
|
||||
type="text"
|
||||
onInput={(e) =>
|
||||
setUserDetails((prev) => ({ ...prev, username: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
icon={<LiaLockSolid />}
|
||||
ref={(input) => (form.current.password = input)}
|
||||
type="password"
|
||||
error={{
|
||||
status: error.error === "password",
|
||||
message: error.message,
|
||||
}}
|
||||
onInput={(e) =>
|
||||
setUserDetails((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className={styles.form_footer}>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
width={20}
|
||||
color="#4085b3"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={buttonDisabled || isLoading}>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
121
src/components/administrator/LoginForm/loginform.module.css
Normal file
121
src/components/administrator/LoginForm/loginform.module.css
Normal file
@@ -0,0 +1,121 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
height: fit-content;
|
||||
padding: 15px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
z-index: 99;
|
||||
box-shadow: rgba(0, 0, 0, 0.119) 0px 0px 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: #ddb1b1;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: #b1ddb1;
|
||||
}
|
||||
}
|
||||
|
||||
.form_login {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form_header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: #323232;
|
||||
margin-bottom: 30px;
|
||||
line-height: .8;
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 90%;
|
||||
align-items: flex-end;
|
||||
|
||||
}
|
||||
|
||||
.form_footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
border: none;
|
||||
background-color: #6fb7e8;
|
||||
color: #fff;
|
||||
padding: 7px 15px;
|
||||
font-size: 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all .3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #4085b3;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/components/administrator/MiniWondow/mini.window.js
Normal file
101
src/components/administrator/MiniWondow/mini.window.js
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import styles from "@/components/administrator/MiniWondow/miniwindow.module.css";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
hideMiniWindowOnIndex,
|
||||
selectMiniWindow,
|
||||
} from "@/utils/store/features/ui/miniwindow.slice";
|
||||
|
||||
// icons
|
||||
import { IoClose } from "react-icons/io5";
|
||||
|
||||
const MiniWindowComponent = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{props.status && (
|
||||
<motion.div
|
||||
initial={{ y: 800, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 800, opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={styles.mini_container}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
height: props.height
|
||||
? props.height
|
||||
: `calc(100% - (${props.index + 1} * ${
|
||||
props.space ? props.space : 100
|
||||
}px))`,
|
||||
}}>
|
||||
<IoClose
|
||||
className={styles.close}
|
||||
onClick={() => {
|
||||
dispatch(hideMiniWindowOnIndex(props.index));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{(() => {
|
||||
if (!props.component) return <>No Component is Set</>;
|
||||
const Component = props.component;
|
||||
return <Component {...props} />;
|
||||
})()}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MiniWindow = () => {
|
||||
const dispatch = useDispatch();
|
||||
const miniWindow = useSelector(selectMiniWindow);
|
||||
|
||||
// useEffect(() => {
|
||||
// const handleKeyDown = (e) => {
|
||||
// if (e.key === "Escape") {
|
||||
// dispatch(hideMiniWindowOnIndex(miniWindow.length - 1));
|
||||
// }
|
||||
// };
|
||||
|
||||
// document.addEventListener("keydown", handleKeyDown);
|
||||
// return () => {
|
||||
// document.removeEventListener("keydown", handleKeyDown);
|
||||
// };
|
||||
// }, [miniWindow, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{miniWindow.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
onClick={(e) => {
|
||||
dispatch(hideMiniWindowOnIndex(miniWindow.length - 1));
|
||||
}}
|
||||
className={styles.container}>
|
||||
{miniWindow.map((item, index) => (
|
||||
<MiniWindowComponent
|
||||
key={index}
|
||||
{...item}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniWindow;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user