first commit

This commit is contained in:
2025-12-30 14:42:30 +07:00
commit 171e2fe58b
254 changed files with 40353 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/categories/[slug]/category.add.module.css";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllCategories, getCategoryById } from "@/models/term";
import { BsArrowLeft } from "react-icons/bs";
import CategoryEditor from "@/components/administrator/CategoryEditor/category.editor";
import { modifyByKeys } from "@/models/helper";
const CategoryItem = async ({ params }) => {
try {
const query = await params;
const id = query.slug;
const category = await getCategoryById(id);
const categories = await getAllCategories();
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard/categories">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<CategoryEditor
data={modifyByKeys(category, ["parents"], (parents) =>
parents.map((parent) => parent._id)
)}
categories={categories}
/>
</div>
</div>
);
} catch (error) {
return notFound();
}
};
export default CategoryItem;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/categories/categories.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
import { getCategories } from "@/models/term";
import CategoriesData from "@/components/administrator/CategoriesData/categories.data";
const CategoriesPage = async ({ searchParams }) => {
const query = await searchParams;
const sort = query?.sort ? JSON.parse(query.sort) : { name: 1 };
let page = parseInt(query?.page) || 1;
const search = query?.search || null;
const limit = 50;
if (search) page = 1;
const categories = await getCategories({ page, limit }, sort, search);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<CategoriesData
initialData={categories}
limit={limit}
sort={sort}
/>
</div>
</div>
);
};
export default CategoriesPage;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,92 @@
"use server";
import React from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/posts/[slug]/add.module.css";
import { getPostMaintainById } from "@/models/posts.model";
import { getPostCategories, getTags } from "@/models/term";
import { getJadwalPelatihanByPostId } from "@/models/jadwal.pelatihan";
import { isEmptyObject, spliceObject } from "@/utils/library";
import { decodeAccessToken, getCookieByName } from "@/utils/cookie";
import PostEditor from "@/components/administrator/PostEditor/post.editor";
import PostLock from "@/providers/post.lock";
// icons
import { BsArrowLeft } from "react-icons/bs";
import { getBridgeSettings } from "@/models/settings";
const PostManagement = async ({ params, searchParams }) => {
try {
const { slug: _id } = await params;
const query = await searchParams;
const postData = await getPostMaintainById(_id);
const categories = await getPostCategories();
const tags = await getTags();
const jadwalPelatihanList = await getJadwalPelatihanByPostId(_id);
const tab = parseInt(query?.tab) || 0;
// id lock
const lockPost = await getCookieByName("lockPost");
const user = await decodeAccessToken();
const currentSatus = postData?.status;
if (isEmptyObject(postData)) return notFound();
// splice post_lock
const { selected: posteditor_meta, spliced: data } = spliceObject(
postData,
["post_lock", "post_publish"],
false
);
const post_lock = {
own: posteditor_meta?.post_lock?.user_id === user?.data?._id,
user: posteditor_meta?.post_lock?.user,
postLockData: posteditor_meta.post_lock,
};
// get Join Bridge
const joinBridge = await getBridgeSettings();
console.log({ joinBridge });
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard/posts">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<PostLock>
<PostEditor
uid={lockPost}
tab={tab}
post_lock={post_lock}
post_publish={posteditor_meta?.post_publish}
currentSatus={currentSatus}
categories={categories}
tags={tags}
data={data}
additional_data={{
jadwalPelatihanList,
joinBridge: joinBridge?.endpoints,
}}
/>
</PostLock>
</div>
</div>
);
} catch (error) {
console.log({ error });
notFound();
}
};
export default PostManagement;

View File

@@ -0,0 +1,46 @@
"use server";
import React from "react";
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/posts/posts.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
import { getPosts } from "@/services/posts";
import PostsData from "@/components/administrator/PostData/posts.data";
import PostLock from "@/providers/post.lock";
import { getAllLockPost } from "@/models/posts.model";
const PostsPage = async ({ searchParams }) => {
const querySearch = await searchParams;
const page = querySearch?.page || 1;
const limit = 50;
const posts = await getPosts({ page: parseInt(page), limit });
// lock post data
const lockPostData = await getAllLockPost();
/*
* title, subtitle, description, content, slug, status, categories, tags, image, author
*/
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<PostLock>
<PostsData
initialData={posts}
limit={limit}
lockPostData={lockPostData}
/>
</PostLock>
</div>
</div>
);
};
export default PostsPage;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import styles from "@/app/(Admin)/dashboard/(Posts Zone)/tags/[slug]/tags.add.module.css";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTagById } from "@/models/term";
import { BsArrowLeft } from "react-icons/bs";
import TagEditor from "@/components/administrator/TagEditor/tag.editor";
const TagItem = async ({ params }) => {
try {
const query = await params;
const id = query.slug;
const tag = await getTagById(id);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard/tags">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<TagEditor data={tag} />
</div>
</div>
);
} catch (error) {
console.log(error);
return notFound();
}
};
export default TagItem;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import styles from "@/app//(Admin)/dashboard/(Posts Zone)/tags/tags.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
import TagsData from "@/components/administrator/TagData/tag.data";
import { getTagsPagination } from "@/models/term";
const TagsPage = async ({ searchParams }) => {
const query = await searchParams;
const sort = query?.sort ? JSON.parse(query.sort) : { name: 1 };
let page = parseInt(query?.page) || 1;
const search = query?.search || null;
const limit = 100;
if (search) page = 1;
const tags = await getTagsPagination({ page, limit }, sort, search);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<TagsData
initialData={tags}
limit={limit}
sort={sort}
/>
</div>
</div>
);
};
export default TagsPage;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import styles from "@/app/(Admin)/dashboard/(Users)/users-login/userslogin.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
import { getAllToken, getTokenBy } from "@/models/users";
import UsersLoginData from "@/components/administrator/UsersLogin/user.online.data";
import { decodeRefreshToken } from "@/utils/cookie";
const UsersLogin = async () => {
const uid = (await decodeRefreshToken()).data?.uid;
const allToken = await getAllToken();
const activeUsers = allToken.filter((item) => item.uid !== uid);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<UsersLoginData initialData={activeUsers} />
</div>
</div>
);
};
export default UsersLogin;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,40 @@
import React from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import styles from "@/app/(Admin)/dashboard/(Users)/users/[slug]/user.add.module.css";
import UserEditor from "@/components/administrator/UserEditor/user.editor";
import { getUserById } from "@/services/users";
import { BsArrowLeft } from "react-icons/bs";
const UserItem = async ({ params }) => {
try {
const query = await params;
const id = query.slug;
const user = await getUserById(id);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard/users">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<UserEditor data={user} />
</div>
</div>
);
} catch (error) {
console.log({ error });
return notFound();
}
};
export default UserItem;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,35 @@
"use server";
import React from "react";
import styles from "@/app/(Admin)/dashboard/(Users)/users/users.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
import UsersData from "@/components/administrator/UserData/user.data";
import { getAllUserExcept } from "@/services/users";
import { cookies } from "next/headers";
import { JWT } from "@/utils/jwt";
const UsersPage = async () => {
const jwt = new JWT();
const cookie = await cookies();
const accessToken = cookie.get("accessToken")?.value;
const detail = jwt.decodeToken(accessToken);
const users = await getAllUserExcept([detail._id]);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<UsersData initialData={users} />
</div>
</div>
);
};
export default UsersPage;

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

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

View File

@@ -0,0 +1,193 @@
.dashboard {
width: 100vw;
height: 100vh;
display: flex;
position: relative;
}
.content {
width: 100%;
height: 100vh;
max-height: 100vh;
overflow-y: auto;
background-color: #f5f7fa;
}
.header_container {
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid #e5e5e5;
position: sticky;
top: 0;
background-color: #fff;
z-index: 999;
padding: 0 20px;
}
.container {
padding: 10px;
}
.analytic_box {
width: 100%;
height: fit-content;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
padding: 10px;
padding-bottom: 30px;
gap: 20px;
}
.analytic_box_item {
width: 200px;
min-width: 200px;
height: 70px;
display: flex;
align-items: center;
border: .5px solid #ededed;
box-shadow: 0 0 12px 1px #2b2b2b1a;
border-radius: 5px;
padding: 10px 20px;
gap: 15px;
}
.item_data_title {
font-size: 11px;
font-weight: 300;
}
.item_data_value {
font-size: 20px;
font-weight: 600;
}
.item_icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #e5f5ff;
}
.icon {
font-size: 19px;
color: #4085b3;
}
.analytic_charts {
width: 100%;
height: fit-content;
display: flex;
gap: 10px;
}
.analytic_table {
width: 500px;
min-width: 500px;
height: 100%;
max-height: 450px;
display: flex;
flex-direction: column;
gap: 10px;
}
.header_container_table {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
gap: 20px;
padding: 0 30px;
min-height: 50px;
}
.header_title_table {
width: fit-content;
font-size: 13px;
font-weight: 300;
p>span {
font-size: 16px;
font-weight: 500;
}
}
.header_action {
width: fit-content;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}
.body_container {
width: 100%;
height: fit-content;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 10px;
gap: 10px;
}
.table_item {
border: .5px solid #ededed;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
transition: all .2s ease;
&:hover {
box-shadow: 0 0 12px 1px #2b2b2b1a;
border-color: #4085b3;
}
}
.table_content {
display: flex;
flex-direction: column;
>span {
font-size: 10px;
font-weight: 400;
}
>p {
font-size: 13px;
font-weight: 600;
}
}
.table_action {
display: flex;
gap: 15px;
align-items: center;
>span {
display: flex;
align-items: center;
gap: 7px;
}
}
.action {
cursor: pointer;
transition: all .2s ease;
&:hover {
color: #4085b3;
}
}

View File

@@ -0,0 +1,33 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}

View File

@@ -0,0 +1,43 @@
"use server";
import React from "react";
import Link from "next/link";
import styles from "@/app/(Admin)/dashboard/gallery/gallery.module.css";
import GalleryComponent from "@/components/administrator/Gallery/gallery";
import { BsArrowLeft } from "react-icons/bs";
import { getGallery } from "@/models/gallery.model";
const GalleryPage = async ({ searchParams }) => {
const query = await searchParams;
let page = query?.page || 1;
const limit = 50;
const sort = query?.sort ? JSON.parse(query.sort) : { createdAt: -1 };
const search = query?.search || null;
if (search) page = 1;
const galleryData = await getGallery(
{ page: parseInt(page), limit },
sort,
search
);
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<GalleryComponent initialData={galleryData} />
</div>
</div>
);
};
export default GalleryPage;

View File

@@ -0,0 +1,34 @@
import React from "react";
import styles from "./dashboard.module.css";
import Sidebar from "@/components/administrator/Sidebar/sidebar";
import UserProvider from "@/providers/user.provider";
import { 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;

View File

@@ -0,0 +1,73 @@
"use client";
import Button from "@/components/ui/Button/button";
import { useRouter } from "next/navigation";
import React from "react";
import { IoIosArrowRoundBack } from "react-icons/io";
const NotFound = () => {
const router = useRouter();
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}>
<p
style={{
fontSize: "15px",
fontWeight: "300",
width: "100%",
}}>
Error Code 404
</p>
<div
style={{
fontSize: "50px",
fontWeight: "bold",
marginBottom: "20px",
}}>
OOOPS! 😥
</div>
<p style={{ fontSize: "15px", fontWeight: "200" }}>
This is not the page you are looking for
</p>
<p style={{ fontSize: "15px", fontWeight: "400" }}>
Maybe youre lost, let me help you out.
</p>
<Button
onClick={() => {
router.back();
}}
style={{
marginTop: "20px",
fontSize: "13px",
padding: "5px 25px",
borderRadius: "5px",
display: "flex",
alignItems: "center",
gap: "10px",
backgroundColor: "#3d3d3d",
color: "#fff",
}}>
<IoIosArrowRoundBack size={20} />
Back
</Button>
</div>
</div>
);
};
export default NotFound;

View File

@@ -0,0 +1,136 @@
import ViewRealtime from "@/components/administrator/ViewRealtime/view.realtime";
import ViewsChart from "@/components/administrator/ViewsChart/views.chart";
import {
getAnalytics,
newestPost,
popularPost,
totalCategories,
totalDraft,
totalPost,
totalPostPublished,
} from "@/models/analytics";
import React from "react";
import styles from "@/app/(Admin)/dashboard/dashboard.module.css";
import { description as defaultDesctription } from "@/config/default";
// icons
import { IoDocumentTextOutline, IoEyeOutline } from "react-icons/io5";
import { TbCategory2 } from "react-icons/tb";
import { RiDraftLine } from "react-icons/ri";
import { HiOutlineDocumentDuplicate } from "react-icons/hi2";
import { VscShare } from "react-icons/vsc";
import { getGeneralSettings } from "@/models/settings";
const Dashboard = async ({ searchParams }) => {
const query = await searchParams;
const viewchart = query?.viewchart ? JSON.parse(query.viewchart) : null;
const data = await getAnalytics(viewchart?.timeline, viewchart?.year);
const popularPostData = await popularPost();
const newestPostData = await newestPost();
let description = await getGeneralSettings();
if (!description) description = defaultDesctription;
const analyticsBox = await Promise.all(
[
{
title: "Published Post",
get: totalPostPublished,
icon: <IoDocumentTextOutline className={styles.icon} />,
},
{
title: "Draft Post",
get: totalDraft,
icon: <RiDraftLine className={styles.icon} />,
},
{
title: "Total Post",
get: totalPost,
icon: <HiOutlineDocumentDuplicate className={styles.icon} />,
},
{
title: "Total Categories",
get: totalCategories,
icon: <TbCategory2 className={styles.icon} />,
},
].map(async (analytic) => {
const value = await analytic.get();
delete analytic.get;
return { ...analytic, value };
})
);
return (
<>
<div className={styles.header_container}>
<div className={styles.header_title}>Dashboard {description.title}</div>
<div className={styles.header_action}></div>
</div>
<div className={styles.container}>
<div className={styles.analytic_box}>
{analyticsBox.map((analytic, index) => (
<div
className={styles.analytic_box_item}
key={index}>
<div className={styles.item_icon}>{analytic.icon}</div>
<div className={styles.item_data}>
<div className={styles.item_data_title}>{analytic.title}</div>
<div className={styles.item_data_value}>{analytic.value}</div>
</div>
</div>
))}
</div>
<div className={styles.analytic_charts}>
<ViewsChart initialData={data} />
{/* Realtime */}
{/* <ViewRealtime /> */}
{/* Popular post */}
<div className={styles.analytic_table}>
<div className={styles.header_container_table}>
<div className={styles.header_title_table}>
<p>
<span>Popular Post</span>
</p>
<p>Top 10 post with most views of all time</p>
</div>
<div className={styles.header_action}></div>
</div>
<div className={styles.body_container}>
{popularPostData.length > 0 ? (
<>
{popularPostData.map((item, index) => (
<div
className={styles.table_item}
key={index}>
<div className={styles.table_content}>
<p>{item.title}</p>
<span>{item.author.name}</span>
</div>
<div className={styles.table_action}>
<span>
<IoEyeOutline size={12} /> {item.views}
</span>
<div className={styles.action}>
<VscShare size={16} />
</div>
</div>
</div>
))}
</>
) : (
<></>
)}
</div>
</div>
</div>
</div>
</>
);
};
export default Dashboard;

View File

@@ -0,0 +1,13 @@
import SettingGeneral from "@/components/administrator/SettingGeneral/setting.general";
import { description } from "@/config/default";
import { getGeneralSettings } from "@/models/settings";
const GeneralSettings = async () => {
let result = await getGeneralSettings();
if (!result) result = description;
return <SettingGeneral generalSetting={result} />;
};
export default GeneralSettings;

View File

@@ -0,0 +1,18 @@
import JoinBridge from "@/components/administrator/JoinBridge/join.bridge";
import { getBridgeSettings, getGeneralSettings } from "@/models/settings";
import React from "react";
const JoinBridges = async () => {
const data = await getBridgeSettings();
const general = await getGeneralSettings();
return (
<JoinBridge
bridgeSettings={data}
status={!!general?.CMS_ID}
ID={general?.CMS_ID}
/>
);
};
export default JoinBridges;

View File

@@ -0,0 +1,35 @@
import React from "react";
import styles from "@/app/(Admin)/dashboard/settings/setting.module.css";
import Link from "next/link";
import { BsArrowLeft } from "react-icons/bs";
const SettingLayout = ({ children }) => {
return (
<div className={styles.container}>
<div className={styles.header}>
<Link
className={styles.back}
href="/dashboard">
<BsArrowLeft /> back
</Link>
</div>
<div className={styles.body}>
<div className={styles.content}>
<div className={styles.content_header}>
<div className={styles.content_title}>
<h3>Settings</h3>
<p>
Manage your website. Be careful change settings, it can break
your website if you don`t know what you are doing.
</p>
</div>
</div>
<div className={styles.content_body}>{children}</div>
</div>
</div>
</div>
);
};
export default SettingLayout;

View File

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

View File

@@ -0,0 +1,75 @@
.container {
width: 100%;
height: fit-content;
min-height: 100%;
display: flex;
align-items: center;
flex-direction: column;
}
.header {
width: 100%;
height: fit-content;
padding: 10px;
border-bottom: 1px solid #dddddd;
}
.body {
width: 100%;
height: fit-content;
}
.back {
width: fit-content;
height: fit-content;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: #444444;
font-weight: 500;
font-size: 13px;
}
.content {
display: flex;
flex-direction: column;
padding: 20px;
height: fit-content;
min-height: calc(100vh - 40px);
width: 100%;
}
.content_header {
width: 100%;
height: fit-content;
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.content_title {
display: flex;
flex-direction: column;
>h3 {
font-size: 21px;
}
>p {
font-size: 15px;
font-weight: 300;
}
}
.content_body {
width: 100%;
padding: 10px;
height: fit-content;
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@@ -0,0 +1,68 @@
.container {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-image: linear-gradient(to top right, #240e00, #823c03);
}
.login_container {
width: 80%;
max-width: 900px;
height: 500px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
overflow: hidden;
box-shadow: rgba(0, 0, 0, 0.119) 0px 0px 15px;
}
.login_hero {
width: 60%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.login_image {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.5s ease-in-out;
&:hover {
transform: scale(1.1);
.image {
opacity: 1;
}
}
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
overflow: hidden;
float: left;
opacity: 0.7;
transition: all 0.5s ease-in-out;
}
.login_box {
width: 40%;
height: 100%;
min-width: 360px;
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8feff;
}

View File

@@ -0,0 +1,38 @@
import React, { Suspense } from "react";
import styles from "@/app/(Admin)/login/login.module.css";
import LoginForm from "@/components/administrator/LoginForm/login.form";
import SafeImage from "@/components/ui/SafeImage/safeImage";
import { description as defaultDescription } from "@/config/default";
import { getGeneralSettings } from "@/models/settings";
const Login = async () => {
let description = await getGeneralSettings();
if (!description) description = defaultDescription;
return (
<div className={styles.container}>
<div className={styles.login_container}>
<div className={styles.login_hero}>
<div className={styles.login_image}>
<SafeImage
className={styles.image}
width={500}
height={500}
src="/images/login_hero.jpg"
alt="login"
priority
/>
</div>
</div>
<div className={styles.login_box}>
<Suspense fallback={<div>Preparing</div>}>
<LoginForm description={description} />
</Suspense>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,27 @@
"use server";
import React from "react";
import styles from "@/app/(Admin)/unauthorize/unauthorize.module.css";
import { EB_Garamond } from "next/font/google";
const ebGaramond = EB_Garamond({
variable: "--font-eb-garamond",
weight: ["400", "500", "600", "700", "800"],
subsets: ["latin"],
});
const Unauthorize = async () => {
return (
<div className={`${styles.container} ${ebGaramond.className}`}>
<div className={styles.error}>
<h1 className={styles.code}>401</h1>
<span className={styles.line}></span>
<p className={styles.message}>Unauthorized</p>
<p>You don`t have valid credential to access this page.</p>
</div>
</div>
);
};
export default Unauthorize;

View File

@@ -0,0 +1,36 @@
.container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.error {
height: fit-content;
width: fit-content;
display: flex;
flex-direction: column;
align-items: center;
}
.code {
font-size: 80px;
display: flex;
gap: 50px;
font-weight: 700;
}
.line {
width: 100%;
height: 1px;
background-color: black;
}
.message {
padding-top: 20px;
font-size: 25px;
text-align: center;
font-weight: 500;
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
export async function GET(request, { params }) {
const { searchParams } = new URL(request.url);
const query = Object.fromEntries(searchParams.entries());
try {
const paths = (await params).path.join("/");
const fullpath = path.join(process.cwd(), "public/media", paths);
await fs.access(fullpath);
const ext = path.extname(fullpath).toLowerCase();
const mimeTypes = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
let imageBuffer = await fs.readFile(fullpath);
if (ext !== ".svg") {
const width = query.w ? parseInt(query.w) : null;
const height = query.h ? parseInt(query.h) : null;
imageBuffer = await sharp(imageBuffer).resize(width, height).toBuffer();
}
const stat = await fs.stat(fullpath);
return new NextResponse(imageBuffer, {
status: 200,
headers: {
"Content-Type": mimeTypes[ext] || "application/octet-stream",
"Access-Control-Allow-Origin": "*",
"Content-Length": imageBuffer.length.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
"Last-Modified": stat.mtime.toUTCString(),
ETag: `"${stat.mtime.getTime()}-${imageBuffer.length}"`,
},
});
} catch (error) {
const width = query.w ? parseInt(query.w) : 400;
const height = query.h ? parseInt(query.h) : 200;
const text = searchParams.get("text") || "Not Found";
const bg = searchParams.get("bg") || "#eee";
const color = searchParams.get("color") || "#999";
let fontSize = searchParams.get("font-size")
? parseInt(searchParams.get("font-size"))
: Math.floor(height * 0.3);
const factor = 0.6;
const estimatedTextWidth = text.length * fontSize * factor;
if (estimatedTextWidth > width * 0.9) {
fontSize = Math.floor((width * 0.9) / (text.length * factor));
}
// langsung bikin SVG string
const svgImage = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<rect width="100%" height="100%" fill="${bg}" />
<text
x="50%"
y="50%"
font-size="${fontSize}"
text-anchor="middle"
dominant-baseline="middle"
fill="${color}"
font-family="Arial, sans-serif"
>
${text}
</text>
</svg>
`;
return new NextResponse(svgImage, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "no-store",
},
});
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(request, { params }) {
try {
const paths = (await params).path.join("/");
const fullpath = path.join(process.cwd(), "/public/media", paths);
if (!fs.existsSync(fullpath))
return NextResponse.json({ error: "not found" }, { status: 404 });
const fileBuffer = fs.readFileSync(fullpath);
const filestat = fs.statSync(fullpath);
const ext = path.extname(fullpath).toLowerCase();
const mimeTypes = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": mimeTypes[ext] || "application/octet-stream",
"Access-Control-Allow-Origin": "*",
"Content-Length": filestat.size.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
"Last-Modified": filestat.mtime.toUTCString(),
ETag: `"${filestat.mtime.getTime()}-${filestat.size}"`,
},
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
}

View File

@@ -0,0 +1,77 @@
import { getPostBySlug } from "@/services/posts";
import { getOrigin } from "@/models/helper";
import { notFound } from "next/navigation";
import { description as defaultDescription } from "@/config/default";
import React from "react";
import Script from "next/script";
import RichContent from "@/components/web/RichContent/rich.content";
import { getGeneralSettings } from "@/models/settings";
// generate metadata
export async function generateMetadata({ params }) {
const param = await params;
const slug = param.slug;
const post = await getPostBySlug(slug);
let description = await getGeneralSettings();
if (!description) description = defaultDescription;
return {
title: post.metadata.title + " - " + description.title,
description: post.metadata.description,
openGraph: {
title: post.metadata.title,
description: post.metadata.description,
images: post.metadata.imagedata.url,
type: "article",
tags: post.tags.map((tag) => tag.name),
},
};
}
const PostContent = async ({ params }) => {
const param = await params;
const slug = param.slug;
const post = await getPostBySlug(slug);
const origin = await getOrigin();
if (!post) return notFound();
return (
<>
{/* JSON-LD */}
<Script
id="jsonld"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
{
"@context": "https://schema.org",
"@type": "Article",
headline: post.metadata.title,
alternativeHeadline: post.title,
image: post.metadata.imagedata.url,
author: "Arena Wisata",
keywords: post.tags.map((tag) => tag.name),
url: origin,
datePublihed: post.createdAt,
dateCreated: post.createdAt,
dateModified: post.modifiedAt,
},
null,
2
),
}}
/>
{/* Post Component */}
<>
{post.title}
<RichContent html={post.content} />
</>
</>
);
};
export default PostContent;

View File

@@ -0,0 +1,25 @@
import clientPromise, { database } from "@/db.config";
import { getOrigin } from "@/models/helper";
export async function generateSitemaps() {
// Fetch the total number of posts and calculate the number of sitemaps needed
return [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
}
export default async function sitemap({ id }) {
// Google's limit is 50,000 URLs per sitemap
const skip = (id - 1) * 50000;
const limit = skip + 50000;
const origin = await getOrigin();
const client = await clientPromise;
const db = client.db(database);
const postCollection = db.collection("posts");
const posts = await postCollection.find({}).skip(skip).limit(limit).toArray();
return posts.map((post) => ({
url: `${origin}/post/${post.slug}`,
lastModified: post.modifiedAt,
}));
}

View File

@@ -0,0 +1,69 @@
import {
deleteMedia,
getGallery,
updateGalleryById,
} from "@/models/gallery.model";
import { response } from "@/utils/helper";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const searchParamsData = Object.fromEntries(searchParams.entries());
const queryRole = {
page: parseInt(searchParamsData?.page) || 1,
limit: parseInt(searchParamsData?.limit) || 100,
sortBy: searchParamsData?.sortBy || "createdAt",
with: searchParamsData?.with || "desc",
search: searchParamsData?.search || null,
};
const sortRole = {
asc: 1,
desc: -1,
};
const galleries = await getGallery(
{ page: queryRole.page, limit: queryRole.limit },
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
queryRole.search
);
return response(200, "Get gallery data successfully", galleries);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to get galleries data",
});
}
}
export async function PATCH(request) {
try {
const data = await request.json();
if (!data?._id)
return response(400, "Bad Request", null, { error: "ID is required" });
const result = await updateGalleryById(data);
return response(200, "Update gallery data successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to update data",
});
}
}
export async function POST(request) {
try {
const body = await request.json();
const result = await Promise.all(
body.data.map(async (item) => await deleteMedia(item))
);
return response(200, "Delete media successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to Delete Media",
});
}
}

View File

@@ -0,0 +1,27 @@
import {
deleteJadwalPelatihanById,
updateJadwalPelatihan,
} from "@/models/jadwal.pelatihan";
import { NextResponse } from "next/server";
export async function PATCH(request) {
try {
const body = await request.json();
const result = await updateJadwalPelatihan(body);
return NextResponse.json(result, { status: 200 });
} catch (error) {
return NextResponse.json({ error: "Failed" }, { status: 500 });
}
}
export async function DELETE(request) {
try {
const { searchParams } = new URL(request.url);
const _id = searchParams.get("_id");
// const body = await request.json();
const result = await deleteJadwalPelatihanById(_id);
return NextResponse.json(result, { status: 200 });
} catch (error) {
console.log(error);
return NextResponse.json({ error: "Failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,6 @@
import { response } from "@/utils/helper";
export async function GET(req, { params }) {
const paramsdata = await params;
return response(200, "Get post data successfully", paramsdata);
}

View File

@@ -0,0 +1,15 @@
import { toTrashMultiple } from "@/models/posts.model";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const data = await request.json();
const result = await toTrashMultiple(data);
return response(200, "Delete post data successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to delete data",
});
}
}

View File

@@ -0,0 +1,26 @@
import { unpublish, publishPost } from "@/models/posts.model";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const body = await request.json();
const { method, data } = body;
const on = {
unpublish: async (data) => {
return Promise.all(data.map(async (id) => await unpublish(id)));
},
publish: async (data) => {
return Promise.all(data.map(async (_data) => await publishPost(_data)));
},
};
const result = await on[method](data);
return response(200, `${method} post successfully`, result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to update data",
});
}
}

View File

@@ -0,0 +1,15 @@
import { revertPublish } from "@/models/posts.model";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const data = await request.json();
const result = await revertPublish(data.id);
return response(200, "Revert post data successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to update data",
});
}
}

View File

@@ -0,0 +1,45 @@
import { getPosts, updatePostMaintainById } from "@/models/posts.model";
import { response } from "@/utils/helper";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const searchParamsData = Object.fromEntries(searchParams.entries());
const queryRole = {
page: parseInt(searchParamsData?.page) || 1,
limit: parseInt(searchParamsData?.limit) || 100,
sortBy: searchParamsData?.sortBy || "createdAt",
with: searchParamsData?.with || "desc",
search: searchParamsData?.search || null,
};
const sortRole = {
asc: 1,
desc: -1,
};
const posts = await getPosts(
{ page: queryRole.page, limit: queryRole.limit },
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
queryRole.search
);
return response(200, "Get post data successfully", posts);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to get post data",
});
}
}
export async function PATCH(request) {
try {
const data = await request.json();
const result = await updatePostMaintainById(data);
return response(200, "Update post data successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to update data",
});
}
}

View File

@@ -0,0 +1,31 @@
import { description } from "@/config/default";
import { updateSettings } from "@/models/settings";
import { response } from "@/utils/helper";
export async function PATCH(request) {
try {
const { searchParams } = new URL(request.url);
const query = Object.fromEntries(searchParams.entries());
let body = await request.json();
let unset = null;
if (query?.setDefault) {
unset = { CMS_ID: "" };
body = { data: description };
}
if (!body?.data)
return response(400, "Bad Request", null, {
error: "Data is unvalid or empty",
});
const result = await updateSettings(body.data, "general", unset);
return response(200, "Set settings successfully", result);
} catch (error) {
console.log(error);
return response(500, "Internal Server Error", null, {
error: "Failed to set settings",
});
}
}

View File

@@ -0,0 +1,22 @@
import { updateSettings } from "@/models/settings";
import { response } from "@/utils/helper";
export async function PATCH(request) {
try {
let body = await request.json();
if (!body?.data)
return response(400, "Bad Request", null, {
error: "Data is unvalid or empty",
});
const result = await updateSettings(body.data, "bridge");
return response(200, "Set settings successfully", result);
} catch (error) {
console.log(error);
return response(500, "Internal Server Error", null, {
error: "Failed to set settings",
});
}
}

View File

@@ -0,0 +1,21 @@
import { deleteCategoryMultipleId } from "@/models/term";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const body = await request.json();
if (!Array.isArray(body?.data))
return response(400, "Bad Request", null, {
error: "Data type must be array",
});
const result = await deleteCategoryMultipleId(body.data);
return response(200, "Delete category successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to delete data",
});
}
}

View File

@@ -0,0 +1,56 @@
import { getCategories, getChildrenCategoryMultipleId } from "@/models/term";
import { response } from "@/utils/helper";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const searchParamsData = Object.fromEntries(searchParams.entries());
const queryRole = {
page: parseInt(searchParamsData?.page) || 1,
limit: parseInt(searchParamsData?.limit) || 100,
sortBy: searchParamsData?.sortBy || "name",
with: searchParamsData?.with || "desc",
search: searchParamsData?.search || null,
};
const sortRole = {
asc: 1,
desc: -1,
};
const categories = await getCategories(
{
page: queryRole.page,
limit: queryRole.limit,
},
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
queryRole.search
);
return response(200, "Get categories data successfully", categories);
} catch (error) {
console.log({ error });
return response(500, "Internal Server Error", null, {
error: "Failed to get categories data",
});
}
}
// Get children of category by id
export async function POST(request) {
try {
const body = await request.json();
if (!body?.data || !Array.isArray(body?.data))
return response(400, "Bad Request", null, {
error: "Data type must be array",
});
const result = await getChildrenCategoryMultipleId(body.data);
return response(200, "Get categories data successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to get categories data",
});
}
}

View File

@@ -0,0 +1,33 @@
import { createTags, getTagsBySlugs } from "@/models/term";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const body = await request.json();
if (!body?.data)
return response(400, "Bad Request", null, { error: "Data is required" });
// find slug is already or not
const slugs = body.data.map((item) => item.slug);
const tagsExist = await getTagsBySlugs(slugs);
let unavailableTags = body.data;
if (tagsExist.length > 0)
unavailableTags = body.data.filter(
(item) => !tagsExist.map((tag) => tag.slug).includes(item.slug)
);
// insert new tags
let result = [];
if (unavailableTags.length > 0) result = await createTags(unavailableTags);
const mergeResult = [...tagsExist, ...result];
return response(200, "Add tag successfully", mergeResult);
} catch (error) {
console.log(error);
return response(500, "Internal Server Error", null, {
error: "Failed to add tag",
});
}
}

View File

@@ -0,0 +1,20 @@
import { deleteMultipleTag } from "@/models/term";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const body = await request.json();
if (!Array.isArray(body?.data))
return response(400, "Bad Request", null, {
error: "Data type must be array",
});
const result = await deleteMultipleTag(body.data);
return response(200, "Delete tag successfully", result);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to delete data",
});
}
}

View File

@@ -0,0 +1,54 @@
import { getTagsPagination } from "@/models/term";
import { response } from "@/utils/helper";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const searchParamsData = Object.fromEntries(searchParams.entries());
const queryRole = {
page: parseInt(searchParamsData?.page) || 1,
limit: parseInt(searchParamsData?.limit) || 100,
sortBy: searchParamsData?.sortBy || "name",
with: searchParamsData?.with || "desc",
search: searchParamsData?.search || null,
};
const sortRole = {
asc: 1,
desc: -1,
};
const tags = await getTagsPagination(
{
page: queryRole.page,
limit: queryRole.limit,
},
{ [queryRole.sortBy]: sortRole[queryRole.with] || -1 },
queryRole.search
);
return response(200, "Get tags data successfully", tags);
} catch (error) {
console.log({ error });
return response(500, "Internal Server Error", null, {
error: "Failed to get tags data",
});
}
}
export async function POST(request) {
try {
const body = await request.json();
const tags = await getTagsPagination(
{ page: 1, limit: 100 },
{ name: 1 },
body.data,
true
);
return response(200, "Get tags data successfully", tags);
} catch (error) {
return response(500, "Internal Server Error", null, {
error: "Failed to search tags",
});
}
}

View File

@@ -0,0 +1,33 @@
import { getGeneralSettings } from "@/models/settings";
import { Encryption } from "@/utils/encryption";
import { response } from "@/utils/helper";
export async function POST(request) {
try {
const body = await request.json();
if (!body?.data || !body?.data?.ID || !body?.data?.ID.startsWith("ICETEA-"))
return response(400, "Bad Request", null, { error: "Invalid request" });
const general = await getGeneralSettings();
if (!general?.CMS_ID)
return response(403, "Forbidden", null, {
error: "Join bridge not allowed",
});
const data = {
ID: body.data.ID,
timestamp: new Date().toISOString(),
OWN_ID: general.CMS_ID,
};
const API_KEY = new Encryption().encrypt(JSON.stringify(data));
return response(200, "Auth success", { API_KEY });
} catch (error) {
console.log(error);
return response(500, "Internal Server Error", null, {
error: "Auth failed",
});
}
}

View File

@@ -0,0 +1,44 @@
import { objectId } from "@/db.config";
import { getJadwalPelatihanByPostId } from "@/models/jadwal.pelatihan";
import {
createPostMaintain,
getPostMaintainByBridgeId,
} from "@/models/posts.model";
import { getPostCategories, getTags } from "@/models/term";
import { getUserBridge } from "@/models/users";
import { response } from "@/utils/helper";
export async function GET(request, { params, searchParams }) {
try {
const { bridgeId } = await params;
const { searchParams } = new URL(request.url);
const query = Object.fromEntries(searchParams.entries());
let postData = await getPostMaintainByBridgeId(bridgeId);
const bridgeUser = await getUserBridge();
if (!postData) {
if (!Object.keys(query).includes("createnew"))
return response(404, "Post not found", null);
postData = await createPostMaintain(bridgeUser._id, {
bridgeId: objectId(bridgeId),
});
}
const categories = await getPostCategories();
const tags = await getTags();
// Optional Data
const jadwalPelatihan = await getJadwalPelatihanByPostId(postData._id);
return response(200, "Get Post Data Successfully", {
post: postData,
tags,
categories,
additionalData: { jadwalPelatihan },
});
} catch (error) {
return response(500, "Internal Server Error", null, { error });
}
}

View File

@@ -0,0 +1,35 @@
import { getGeneralSettings } from "@/models/settings";
import { Encryption } from "@/utils/encryption";
import { response } from "@/utils/helper";
import { headers } from "next/headers";
export async function GET(request) {
try {
// get header
const header = await headers();
const authorization = header.get("authorization");
if (!authorization)
return response(401, "Unauthorized", null, {
error: "Authorization not found",
});
const bearer = authorization.split("Bearer ")[1];
const general = await getGeneralSettings();
const notAllowedRes = response(403, "Forbidden", null, {
error: "Join bridge not allowed",
});
if (!general?.CMS_ID) return notAllowedRes;
const decryptedData = new Encryption().decrypt(bearer);
const data = JSON.parse(decryptedData);
if (data?.OWN_ID !== general.CMS_ID) return notAllowedRes;
return response(200, "Connection Ready", { access: "grant" });
} catch (error) {
return response(500, "Internal Server Error", null, { error });
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
export async function GET(request, { params }) {
const { searchParams } = new URL(request.url);
const query = Object.fromEntries(searchParams.entries());
try {
const paths = (await params).path.join("/");
const fullpath = path.join(process.cwd(), "public/media", paths);
await fs.access(fullpath);
const ext = path.extname(fullpath).toLowerCase();
const mimeTypes = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
let imageBuffer = await fs.readFile(fullpath);
if (ext !== ".svg") {
const width = query.w ? parseInt(query.w) : null;
const height = query.h ? parseInt(query.h) : null;
imageBuffer = await sharp(imageBuffer).resize(width, height).toBuffer();
}
const stat = await fs.stat(fullpath);
return new NextResponse(imageBuffer, {
status: 200,
headers: {
"Content-Type": mimeTypes[ext] || "application/octet-stream",
"Access-Control-Allow-Origin": "*",
"Content-Length": imageBuffer.length.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
"Last-Modified": stat.mtime.toUTCString(),
ETag: `"${stat.mtime.getTime()}-${imageBuffer.length}"`,
},
});
} catch (error) {
const width = query.w ? parseInt(query.w) : 400;
const height = query.h ? parseInt(query.h) : 200;
const text = searchParams.get("text") || "Not Found";
const bg = searchParams.get("bg") || "#eee";
const color = searchParams.get("color") || "#999";
let fontSize = searchParams.get("font-size")
? parseInt(searchParams.get("font-size"))
: Math.floor(height * 0.3);
const factor = 0.6;
const estimatedTextWidth = text.length * fontSize * factor;
if (estimatedTextWidth > width * 0.9) {
fontSize = Math.floor((width * 0.9) / (text.length * factor));
}
// langsung bikin SVG string
const svgImage = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<rect width="100%" height="100%" fill="${bg}" />
<text
x="50%"
y="50%"
font-size="${fontSize}"
text-anchor="middle"
dominant-baseline="middle"
fill="${color}"
font-family="Arial, sans-serif"
>
${text}
</text>
</svg>
`;
return new NextResponse(svgImage, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "no-store",
},
});
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(request, { params }) {
try {
const paths = (await params).path.join("/");
const fullpath = path.join(process.cwd(), "/public/media", paths);
if (!fs.existsSync(fullpath))
return NextResponse.json({ error: "not found" }, { status: 404 });
const fileBuffer = fs.readFileSync(fullpath);
const filestat = fs.statSync(fullpath);
const ext = path.extname(fullpath).toLowerCase();
const mimeTypes = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": mimeTypes[ext] || "application/octet-stream",
"Access-Control-Allow-Origin": "*",
"Content-Length": filestat.size.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
"Last-Modified": filestat.mtime.toUTCString(),
ETag: `"${filestat.mtime.getTime()}-${filestat.size}"`,
},
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
}

View File

@@ -0,0 +1,73 @@
import clientPromise, { database, objectId } from "@/db.config";
import { normalizeMongoDoc, ProcessPostLock } from "@/models/helper";
import { JWT } from "@/utils/jwt";
import { NextResponse } from "next/server";
export async function POST(request) {
try {
const jwt = new JWT();
const { searchParams } = new URL(request.url);
const post_id = searchParams.get("post_id");
const uid = searchParams.get("uid");
const session = new ProcessPostLock(post_id);
// cookies
const cookies = request.cookies.get("refreshToken")?.value;
const userData = jwt.decodeToken(cookies);
const client = await clientPromise;
const db = client.db(database);
const postslock = db.collection("posts_lock");
return await session.execute(async () => {
console.log({ post_id, userid: userData._id });
// check user
const currentLock = await postslock.findOne({
post_id: objectId(post_id),
});
if (currentLock && currentLock.uid !== uid) {
const user = await db
.collection("users")
.findOne(
{ _id: objectId(currentLock.user_id) },
{ projection: { name: 1 } }
);
return NextResponse.json(
{ status: "unauthorize", user: normalizeMongoDoc(user) },
{ status: 401 }
);
}
await postslock.updateOne(
{ uid, post_id: objectId(post_id) },
{
$set: { heartBeat: new Date() },
$setOnInsert: {
uid,
post_id: objectId(post_id),
lockedSince: new Date(),
user_id: objectId(userData._id),
},
},
{ upsert: true }
);
const indexes = await postslock.listIndexes().toArray();
if (!indexes.map((i) => i?.name).includes("heartBeat_1"))
// create ttl when not update on 10s will be deleted
await postslock.createIndex(
{ heartBeat: 1 },
{ expireAfterSeconds: 10 }
);
return NextResponse.json({ status: "success" }, { status: 200 });
});
} catch (error) {
console.log(error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,14 @@
import { cleanUpExpiredLockPost, getAllLockPost } from "@/models/posts.model";
import { NextResponse } from "next/server";
export async function GET() {
try {
// cleanup
await cleanUpExpiredLockPost();
const result = await getAllLockPost();
return NextResponse.json(result, { status: 200 });
} catch (error) {
return NextResponse.json({ error: "Failed to get posts" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import clientPromise, { database, objectId } from "@/db.config";
import { NextResponse } from "next/server";
export async function POST(request) {
try {
const { searchParams } = new URL(request.url);
const post_id = searchParams.get("post_id");
const uid = searchParams.get("uid");
const client = await clientPromise;
const db = client.db(database);
const postslock = db.collection("posts_lock");
const result = await postslock.deleteOne({
uid,
post_id: objectId(post_id),
});
return NextResponse.json({ status: "success" }, { status: 200 });
} catch (error) {
console.log(error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { uploadFile } from "@/utils/upload.file";
import fs from "fs";
import path from "path";
export async function POST(request) {
try {
const galleryPath = path.join(process.cwd(), "/public/media/gallery");
fs.mkdirSync(galleryPath, { recursive: true });
const resultUpload = await uploadFile(request, {
destinationPath: galleryPath,
resize: true,
});
// db logic
return NextResponse.json({ resultUpload }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { uploadFile } from "@/utils/upload.file";
import clientPromise, { database } from "@/db.config";
import { getOrigin, modifyByKeys } from "@/models/helper";
export async function POST(request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type");
const origin = await getOrigin();
try {
// uploadFile supports multiple files per request.
// but on this endpoint we only support one file
const resultUpload = await uploadFile(request, { resize: true });
if (resultUpload.files.some((file) => file.success === false))
throw new Error("Failed to upload file");
// db logic
const client = await clientPromise;
const mongo = client.db(database);
const media = mongo.collection("media");
const savetodb = await Promise.all(
resultUpload.files.map(async (item) => {
const meta = {
createdAt: new Date(),
url: item.url,
title: item.name,
filename: item.filename,
};
const resultDB = await media.insertOne(meta);
return {
...meta,
_id: resultDB.insertedId,
};
})
);
const result = {
...resultUpload,
files: resultUpload.files.map((file) => {
const data = savetodb.find((item) => item.filename === file.filename);
return modifyByKeys(
{ ...file, ...data },
["url"],
// (url) => `${origin}/api${url}`,
(url) => `${origin}${url}`,
true
);
}),
};
// experimental
if (type === "mediapost")
return NextResponse.json(
{ errorMessage: "", resultUpload },
{ status: 200 }
);
return NextResponse.json(result, { status: 200 });
} catch (error) {
if (type === "mediapost")
return NextResponse.json(
{ errorMessage: "insert error message", result: [] },
{ status: 500 }
);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,36 @@
.container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.error {
height: fit-content;
width: fit-content;
display: flex;
flex-direction: column;
align-items: center;
}
.code {
font-size: 80px;
display: flex;
gap: 50px;
font-weight: 700;
}
.line {
width: 100%;
height: 1px;
background-color: black;
}
.message {
padding-top: 20px;
font-size: 25px;
text-align: center;
font-weight: 500;
}

27
src/app/forbidden/page.js Normal file
View File

@@ -0,0 +1,27 @@
"use server";
import React from "react";
import styles from "@/app/forbidden/forbidden.module.css";
import { EB_Garamond } from "next/font/google";
const ebGaramond = EB_Garamond({
variable: "--font-eb-garamond",
weight: ["400", "500", "600", "700", "800"],
subsets: ["latin"],
});
const Forbidden = async () => {
return (
<div className={`${styles.container} ${ebGaramond.className}`}>
<div className={styles.error}>
<h1 className={styles.code}>403</h1>
<span className={styles.line}></span>
<p className={styles.message}>Forbidden</p>
<p>You do not have permission to access this page.</p>
</div>
</div>
);
};
export default Forbidden;

27
src/app/global-error.js Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import styles from "@/app/forbidden/forbidden.module.css";
import { EB_Garamond } from "next/font/google";
const ebGaramond = EB_Garamond({
variable: "--font-eb-garamond",
weight: ["400", "500", "600", "700", "800"],
subsets: ["latin"],
});
export default function GlobalError({ error, reset }) {
return (
<html>
<body>
<div className={`${styles.container} ${ebGaramond.className}`}>
<div className={styles.error}>
<h1 className={styles.code}>500</h1>
<span className={styles.line}></span>
<p className={styles.message}>Internal Server Error</p>
<p>Sorry, something went wrong. Please try again later.</p>
</div>
</div>
</body>
</html>
);
}

19
src/app/globals.css Normal file
View File

@@ -0,0 +1,19 @@
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}

84
src/app/layout.js Normal file
View File

@@ -0,0 +1,84 @@
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
import { description as defaultDescription } from "@/config/default";
import StoreProvider from "@/utils/store/store.provider";
import Notification from "@/components/ui/Notification/notification";
import Modal from "@/components/administrator/Modal/modal";
import Toast from "@/components/ui/Toast/toast";
import MiniWindow from "@/components/administrator/MiniWondow/mini.window";
import { getOrigin } from "@/models/helper";
import VisitorProvider from "@/providers/visitor.provider";
import { decodeAccessToken, decodeAnonymous } from "@/utils/cookie";
// import { GoogleAnalytics, GoogleTagManager } from "@next/third-parties/google";
// import Script from "next/script";
import G_TagManager from "@/components/google/google.tag.manager";
import G_Analytics from "@/components/google/google.analytics";
import { getGeneralSettings } from "@/models/settings";
// const geistSans = Geist({
// variable: "--font-geist-sans",
// subsets: ["latin"],
// weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
// });
// const geistMono = Geist_Mono({
// variable: "--font-geist-mono",
// subsets: ["latin"],
// });
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
});
export async function generateMetadata() {
const origin = await getOrigin();
let description = await getGeneralSettings();
if (!description) description = defaultDescription;
return {
metadataBase: new URL(origin),
title: description.title,
description: description.description,
openGraph: {
title: description.title,
description: description.description,
images: description.logo,
type: "website",
},
};
}
export default async function RootLayout({ children }) {
const accessToken = await decodeAccessToken();
const anonymous = await decodeAnonymous();
let description = await getGeneralSettings();
if (!description) description = defaultDescription;
return (
<StoreProvider>
<html lang="en">
<body
// className={`${geistSans.variable}`}
className={inter.className}>
<G_TagManager description={description} />
<VisitorProvider
description={description}
userData={accessToken?.data}
anonymous={anonymous?.data}>
{children}
</VisitorProvider>
<MiniWindow />
<Notification />
<Modal />
<Toast />
<G_Analytics description={description} />
</body>
</html>
</StoreProvider>
);
}

473
src/app/library/library.js Normal file
View File

@@ -0,0 +1,473 @@
export const isEmptyObject = (obj) =>
obj && Object.keys(obj)?.length === 0 && obj.constructor === Object;
export const isEmptyValue = (obj) =>
Object.values(obj).some(
(x) => x === "" || x === 0 || x === null || x === undefined
);
export const formatDate = (date, withClock = false) => {
const currentDate = new Date(date);
const hours = currentDate.getHours();
const minutes = currentDate.getMinutes();
return (
currentDate.toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
day: "numeric",
}) +
(withClock
? ` ${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`
: "")
);
};
export const isValidDate = (date) => {
const currentDate = new Date(date);
return !isNaN(currentDate.getTime());
};
export const spliceObject = (obj, keys = [], removeEmpty = true) => {
if (!obj) return;
return {
selected: Object.entries(obj)
.filter(
([key, value]) =>
keys.includes(key) &&
(!removeEmpty || value || typeof value === "boolean")
)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
spliced: Object.entries(obj)
.filter(
([key, value]) =>
!keys.includes(key) &&
(!removeEmpty || value || typeof value === "boolean")
)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
};
};
export const isPromise = (fn) => fn.constructor.name === "AsyncFunction";
export const checkPasswordStrength = (password) => {
let score = 0;
if (!password) return "";
// Check password length
if (password.length > 8) score += 1;
// Contains lowercase
if (/[a-z]/.test(password)) score += 1;
// Contains uppercase
if (/[A-Z]/.test(password)) score += 1;
// Contains numbers
if (/\d/.test(password)) score += 1;
// Contains special characters
if (/[^A-Za-z0-9]/.test(password)) score += 1;
switch (score) {
case 0:
case 1:
case 2:
return "Weak";
case 3:
return "Medium";
case 4:
case 5:
return "Strong";
}
};
export const unmergingCategory = (data) => {
if (!data || data?.length === 0) return [];
return data
.map((item) =>
[
Object.fromEntries(
Object.entries(item).filter(([k, _]) => k !== "children")
),
item.children.length > 0 ? unmergingCategory(item.children) : null,
]
.flat()
.filter((item) => item)
)
.flat();
};
export const mergeCategory = (
parents,
categories = [],
currentDepth,
depth
) => {
if (categories.length === 0) return parents;
return {
...parents,
children: (() => {
const cat = categories.filter((cat) => cat.parents[0] === parents._id);
if (currentDepth < depth)
return cat.map((c) =>
mergeCategory(c, categories, currentDepth + 1, depth)
);
return cat;
})(),
};
};
export const filterEmptyTag = (stringdata) => {
return stringdata.replace(/<(\w+)[^>]*>(\s|&nbsp;|<br\s*\/?>)*<\/\1>/gi, "");
};
export const debounce = (fn, delay, fnForceDelay) => {
let timeoutId;
return (...args) => {
if (fnForceDelay) fn(...args);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
export const generateColor = (alpha = 1) => {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const a = Math.round(alpha * 255); // convert alpha (01) to 0255
// Convert to 2-digit hex and pad with 0 if needed
const toHex = (n) => n.toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`;
};
/**
* @description
* Group an array of objects by given key.
* @param {string} key - The key to group by.
* @param {Array} arr - The array to group.
* @returns {Object} - An object with grouped objects as values, and the key used as the key.
* @example
* const data = [
* { name: 'John', age: 18 },
* { name: 'Jane', age: 19 },
* { name: 'John', age: 18 },
* { name: 'Jane', age: 19 },
* ];
* const grouped = arrayGroupBy('name', data);
* console.log(grouped);
* {
* 'John': [
* { name: 'John', age: 18 },
* { name: 'John', age: 18 }
* ],
* 'Jane': [
* { name: 'Jane', age: 19 },
* { name: 'Jane', age: 19 }
* ]
* }
*/
export const arrayGroupBy = (key, arr) =>
arr.length > 0
? arr.reduce(
(acc, item) =>
!!acc[item[key]]
? { ...acc, [item[key]]: [...acc[item[key]], item] }
: { ...acc, [item[key]]: [item] },
{}
)
: {};
export const getImageSize = async (url) => {
if (!url) return;
try {
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentLength = response.headers.get("content-length");
const type = response.headers.get("content-type");
if (contentLength) {
const bytes = parseInt(contentLength);
const mb = bytes / (1024 * 1024);
return {
bytes: bytes,
kb: (bytes / 1024).toFixed(2),
mb: mb.toFixed(2),
size: formatBytes(bytes),
type: type,
};
}
return null;
} catch (error) {
return { error: error.message };
}
};
export const getImageDimension = async (file) => {
if (!file) return;
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.width,
height: img.height,
});
URL.revokeObjectURL(img.src);
};
img.onerror = reject;
img.src = isObject(file) ? URL.createObjectURL(file) : file;
});
};
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === undefined) return "";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals));
return `${value} ${sizes[i]}`;
};
export const slugify = (str) =>
str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
export const removeDuplicateProp = (data, prop) => {
const unique = new Set();
return data.filter((item) => {
const key = item?.[prop];
if (!key || unique.has(key)) return false;
unique.add(key);
return true;
});
};
export const toRelativeDay = (date) => {
const today = new Date();
const targetDate = new Date(date);
// get diff minutes
today.setSeconds(0, 0, 0, 0);
targetDate.setSeconds(0, 0, 0, 0);
let diffTime = targetDate - today;
const diffMinutes = Math.abs(Math.ceil(diffTime / (1000 * 60)));
if (diffMinutes < 1) return "Just now";
if (diffMinutes < 59) return `${diffMinutes} minutes ago`;
// get diff hours
today.setMinutes(0, 0, 0, 0);
targetDate.setMinutes(0, 0, 0, 0);
diffTime = targetDate - today;
const diffHours = Math.abs(Math.ceil(diffTime / (1000 * 60 * 60)));
if (diffHours < 1) return "Just now";
if (diffHours < 23) return `${diffHours} hours ago`;
today.setHours(0, 0, 0, 0);
targetDate.setHours(0, 0, 0, 0);
diffTime = targetDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return [
{ on: diffDays === 0, text: "Today" },
{ on: diffDays === 1, text: "Tomorrow" },
{ on: diffDays === -1, text: "Yesterday" },
{ on: diffDays > 1, text: `in ${Math.abs(diffDays)} days` },
{
on: diffDays < -1 && diffDays > -7,
text: `${Math.abs(diffDays)} days ago`,
},
{
on: diffDays <= -7 && diffDays >= -31,
text: `${Math.abs(Math.round(diffDays / 7))} weeks ago`,
},
{
on: diffDays < -31 && diffDays >= -365,
text: `${Math.abs(Math.round(diffDays / 30))} months ago`,
},
{
on: diffDays < -365,
text: `${formatDate(date)}`,
},
].find((item) => item.on)?.text;
};
export const isObject = (obj) =>
obj !== null && typeof obj === "object" && !Array.isArray(obj);
export const flatObjectKeys = (obj) =>
isObject(obj)
? Object.entries(obj)
.map(([k, v]) => [k, flatObjectKeys(v)].flat())
.flat()
: [];
export const capitalizeEachWord = (string) =>
string.replace(
/\w\S*/g,
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
export const objectFilterKey = (obj, key) =>
Object.fromEntries(
Object.entries(obj)
.filter(([k, _]) => k !== key)
.map(([k, v]) => [k, isObject(v) ? objectFilterKey(v, key) : v])
);
/**
* @description
* Check if object has key recursively
*/
export const objecthasKey = (obj, key) => flatObjectKeys(obj).includes(key);
export const objecthasKeys = (obj, keys) =>
keys.every((key) => objecthasKey(obj, key));
export const filterEmptyValue = (obj) =>
Object.fromEntries(
Object.entries(obj)
.filter(([_, v]) => v)
.map(([k, v]) => [k, isObject(v) ? filterEmptyValue(v) : v])
);
export const modifyByKeys = (obj, keys, callback, keepData = false) => {
if (!isObject(obj)) return obj;
const startOfKey = keys.map((k) => (k.match(/\./gi) ? k.split(".")[0] : k));
const endOfKey = keys.map((k) =>
k.match(/\./gi) ? k.split(".").slice(-1) : k
);
return Object.fromEntries(
Object.entries({
...obj,
...(keepData
? Object.fromEntries(
endOfKey.filter((k) => k in obj).map((k) => [`_${k}`, obj[k]])
)
: {}),
}).map(([k, v]) => [
k,
startOfKey.includes(k)
? isObject(v)
? modifyByKeys(
v,
keys
.filter((k) => k.match(/\./gi)) // filter only key has dot
.map((k) => k.split(".").slice(1).join(".")), // remove first word before dot
callback,
keepData
) // recursively
: callback(v)
: v,
])
);
};
export const getByKeys = (obj, keys, initialData = {}) => {
// create multidimentional array
const newKeys = keys.map((k) => (k.match(/\./gi) ? k.split(".") : k));
const data = newKeys.reduce(
(acc, key) =>
// my expectation is key is array obj[key[0]] is object
Array.isArray(key) && key[0] in obj
? {
...acc,
// recursively when key is array from splited with dot
// call again with joined key with dot
...getByKeys(obj[key[0]], [key.slice(1).join(".")], acc),
}
: key in obj
? { ...acc, [key]: obj[key] }
: acc,
{}
);
return { ...initialData, ...data };
};
export const generateIndexes = (start, end) => {
const _start = Math.min(start, end);
const _end = Math.max(start, end);
const length = _end - _start + 1;
return Array.from({ length }, (_, index) => index + _start);
};
export const generateRange = (...numbers) => {
const [from, to] = [Math.min(...numbers), Math.max(...numbers)];
return Array(to - from + 1)
.fill()
.map((_, i) => i + from);
};
export const formatDateRange = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
// helper cek valid date
const isValid = (d) => d instanceof Date && !isNaN(d);
if (!isValid(start)) return startDate;
if (!isValid(end)) return endDate;
const pad = (num) => num.toString().padStart(2, "0");
const sameDay = start.toDateString() === end.toDateString();
const sameMonth = start.getMonth() === end.getMonth();
const sameYear = start.getFullYear() === end.getFullYear();
if (sameDay) {
// Kalau start & end sama → tampilkan 1 tanggal
return `${pad(start.getDate())} ${start.toLocaleDateString("id-ID", {
month: "long",
year: "numeric",
})}`;
}
if (sameMonth && sameYear) {
// Contoh: 01 - 05 Januari 2025
return `${pad(start.getDate())} - ${pad(
end.getDate()
)} ${start.toLocaleDateString("id-ID", {
month: "long",
year: "numeric",
})}`;
} else {
// Contoh: 30 Jan - 02 Feb 25
return `${pad(start.getDate())} ${start.toLocaleDateString("id-ID", {
month: "short",
})} - ${pad(end.getDate())} ${end.toLocaleDateString("id-ID", {
month: "short",
year: "2-digit",
})}`;
}
};
export const normalizeURL = (url) => {
return url
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/$/, "");
};

25
src/app/manifest.js Normal file
View File

@@ -0,0 +1,25 @@
import { description as tempDesctiption } from "@/config/default";
import { getGeneralSettings } from "@/models/settings";
export default async function manifest() {
let description = await getGeneralSettings();
if (!description) description = tempDesctiption;
return {
name: description.title,
short_name: description.title,
description: description.description,
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/favicon.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}

13
src/app/page.js Normal file
View File

@@ -0,0 +1,13 @@
import Button from "@/components/ui/Button/button";
import styles from "./page.module.css";
import Link from "next/link";
export default function Home() {
return (
<div className={styles.container}>
<Link href="/dashboard">
<Button>press me!</Button>
</Link>
</div>
);
}

7
src/app/page.module.css Normal file
View File

@@ -0,0 +1,7 @@
.container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}

13
src/app/robots.js Normal file
View File

@@ -0,0 +1,13 @@
import { getOrigin } from "@/models/helper";
export default async function robots() {
const origin = await getOrigin();
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/dashboard/",
},
sitemap: `${origin}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,39 @@
"use client";
import { api } from "@/app/services/config/api.config";
export const galleryServices = {
getGallery: async (
{ page = 1, limit = 100 },
sort = { createdAt: -1 },
search = null
) => {
const [sortBy, order] = Object.entries(sort)[0];
const roleOfSort = { asc: 1, desc: -1 };
const orderBy = Object.entries(roleOfSort).find(
([_, value]) => value === order
)[0];
const params = { page, limit, sortBy, with: orderBy };
if (search) params.search = search;
const response = await api.get("/admin/gallery", { params });
return response?.data;
},
updateGalleryById: async (data) => {
const response = await api.patch("/admin/gallery", data);
return response.data;
},
deleteMedia: async (id) => {
const response = await api.post("/admin/gallery", { data: [id] });
return response.data[0];
},
deleteMediaMultiple: async (data) => {
const response = await api.post("/admin/gallery", { data });
return response.data;
},
};

View File

@@ -0,0 +1,21 @@
const { api } = require("../config/api.config");
export const getData = async (
endpoint,
{ page = 1, limit = 100 },
sort = { name: 1 },
search = null
) => {
const [sortBy, order] = Object.entries(sort)[0];
const roleOfSort = { asc: 1, desc: -1 };
const orderBy = Object.entries(roleOfSort).find(
([_, value]) => value === order
)[0];
const params = { page, limit, sortBy, with: orderBy };
if (search) params.search = search;
const response = await api.get(endpoint, { params });
return response?.data;
};

View File

@@ -0,0 +1,79 @@
"use client";
import { api } from "@/app/services/config/api.config";
import { getData } from "./helper";
export const postsServices = {
getPosts: async ({ page = 1, limit = 100 }, sort, search) => {
const response = await getData(
"/admin/posts",
{ page, limit },
sort,
search
);
return response;
},
searchPosts: async (keyword) => {
const response = await getData(
"/admin/posts",
{ page: 1, limit: 200 },
{ createdAt: -1 },
keyword
);
return response.data;
},
updatePost: async (data) => {
const response = await api.patch("/admin/posts", data);
return response.data;
},
publishPost: async (data) => {
const response = await api.post("/admin/posts/publish", {
method: "publish",
data: [data],
});
return response.data[0];
},
unpublishPost: async (id) => {
const response = await api.post("/admin/posts/publish", {
method: "unpublish",
data: [id],
});
return response.data[0];
},
deletePost: async (id) => {
const response = await api.post("/admin/posts/delete", [id]);
return response.data[0];
},
revertPublish: async (id) => {
const response = await api.post("/admin/posts/revert", { id });
return response.data;
},
publishMultiplePost: async (data) => {
const response = await api.post("/admin/posts/publish", {
method: "publish",
data,
});
return response.data;
},
unpublishMultiplePost: async (data) => {
const response = await api.post("/admin/posts/publish", {
method: "unpublish",
data,
});
return response.data;
},
deleteMultiplePosts: async (data) => {
const response = await api.post("/admin/posts/delete", data);
return response.data;
},
};

View File

@@ -0,0 +1,51 @@
"use client";
import { api } from "@/app/services/config/api.config";
import axios from "axios";
export const settingsServices = {
updateGeneralSetting: async (data) => {
const response = await api.patch("/admin/settings/general", { data });
return response.data;
},
setDefaultGeneralSetting: async () => {
const response = await api.patch(
"/admin/settings/general?setDefault=1",
{}
);
return response.data;
},
getAuthorization: async (url, ID) => {
const protocol = window.location.protocol.replace(":", "");
url = url.replace(protocol + "://", "");
// only check if url is valid, must return status 200
const response = await axios.post(`${protocol}://${url}/api/bridge/auth`, {
data: { ID },
});
return { ...response.data?.data, url: `${protocol}://${url}` };
},
updateBridgeSetting: async (data) => {
const response = await api.patch("/admin/settings/join-bridge", { data });
return response.data;
},
testBridgeUrl: async (url, api_key) => {
const protocol = window.location.protocol.replace(":", "");
url = url.replace(protocol + "://", "");
// only check if url is valid, must return status 200
await axios.get(`${protocol}://${url}/api/bridge`, {
headers: {
Authorization: `Bearer ${api_key}`,
},
});
return `${protocol}://${url}`;
},
};

View File

@@ -0,0 +1,63 @@
"use client";
import { api } from "@/app/services/config/api.config";
import { getData } from "./helper";
export const termServices = {
getCategories: async (
{ page = 1, limit = 100 },
sort = { name: 1 },
search = null
) => await getData("/admin/term/categories", { page, limit }, sort, search),
getTags: async (
{ page = 1, limit = 100 },
sort = { name: 1 },
search = null
) => await getData("/admin/term/tags", { page, limit }, sort, search),
getChildrenById: async (id) => {
const response = await api.post("/admin/term/categories", { data: [id] });
return response.data;
},
getChildMultipleId: async (ids) => {
const response = await api.post("/admin/term/categories", { data: ids });
return response.data;
},
searchTermById: async (ids) => {
const response = await api.post("/admin/term/tags", { data: ids });
return response.data;
},
createMultipleTags: async (data) => {
const response = await api.post("/admin/term/tags/add", { data: data });
return response.data;
},
deleteCategoryById: async (id) => {
const response = await api.post("/admin/term/categories/delete", {
data: [id],
});
return response.data;
},
deteleMultipleCategories: async (ids) => {
const response = await api.post("/admin/term/categories/delete", {
data: ids,
});
return response.data;
},
deleteTagById: async (id) => {
const response = await api.post("/admin/term/tags/delete", { data: [id] });
return response.data[0];
},
deleteMultipleTag: async (ids) => {
const response = await api.post("/admin/term/tags/delete", { data: ids });
return response.data;
},
};

View File

@@ -0,0 +1,18 @@
"use client";
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API || "http://localhost:3000/api",
timeout: 10000,
withCredentials: true,
});
api.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
return Promise.reject(error);
}
);

13
src/app/sitemap.js Normal file
View File

@@ -0,0 +1,13 @@
import { webPages } from "@/config/web";
import { getOrigin } from "@/models/helper";
export default async function sitemap() {
const origin = await getOrigin();
return webPages.map((page) => ({
url: `${origin}${page.route}`,
lastModified: new Date(),
changeFrequency: page.frequency,
priority: page.priority || 0.5,
}));
}

41
src/clients.js Normal file
View 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];
}
}

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

View File

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

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

View File

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

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

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

View File

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

View 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() !== "";
}
}

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

View File

@@ -0,0 +1,7 @@
export const waitingFor = (timeout) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, timeout);
});
};

View 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 ![alt](url)
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 (![alt](url))
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",
};
};

View 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(/&nbsp;/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 += `![${alt}](${block.data.file.url})\n`;
markdown += "\n";
break;
default:
break;
}
});
return markdown.trim();
};

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

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

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

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

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

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

View 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