Initial commit

This commit is contained in:
Anang Yusman 2025-10-20 10:53:39 +08:00
commit 14bef577b5
10665 changed files with 1837923 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

29
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,29 @@
stages:
- build
- deploy
build-dev:
stage: build
when: on_success
only:
- main
image: docker:stable
services:
- name: docker:dind
command: ["--insecure-registry=103.82.242.92:8900"]
script:
- docker logout
- docker login -u $DEPLOY_USERNAME -p $DEPLOY_TOKEN 103.82.242.92:8900
- docker build -t 103.82.242.92:8900/medols/web-warga-bicara:dev .
- docker push 103.82.242.92:8900/medols/web-warga-bicara:dev
auto-deploy:
stage: deploy
when: on_success
only:
- main
image: curlimages/curl:latest
services:
- docker:dind
script:
- curl --user admin:$JENKINS_PWD http://38.47.180.165:8080/job/auto-deploy-warga-bicara/build?token=autodeploymedols

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# Menggunakan image Node.js yang lebih ringan
FROM node:23.5.0-alpine
# Mengatur port
ENV PORT 3000
# Install pnpm secara global
RUN npm install -g pnpm
# Membuat direktori aplikasi dan mengatur sebagai working directory
WORKDIR /usr/src/app
# Menyalin file penting terlebih dahulu untuk caching
COPY package.json ./
# Menyalin direktori ckeditor5 jika diperlukan
COPY vendor/ckeditor5 ./vendor/ckeditor5
# Menyalin env
COPY .env .env
# Install dependencies
RUN pnpm install
# RUN pnpm install --frozen-lockfile
# Menyalin source code aplikasi
COPY . .
# Build aplikasi
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm next build
# Expose port untuk server
EXPOSE 3000
# Perintah untuk menjalankan aplikasi
CMD ["pnpm", "run", "start"]

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,273 @@
"use client";
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
import AdvertiseTable from "@/components/table/advertise/advertise-table";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { useDropzone } from "react-dropzone";
import { close, error, loading } from "@/config/swal";
import useDisclosure from "@/components/useDisclosure";
import {
createAdvertise,
createMediaFileAdvertise,
} from "@/service/advertisement";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Controller, useForm } from "react-hook-form";
import Image from "next/image";
const createArticleSchema = z.object({
title: z.string().min(2, {
message: "Judul harus diisi",
}),
url: z.string().min(1, {
message: "Link harus diisi",
}),
description: z.string().min(2, {
message: "Deskripsi harus diisi",
}),
});
export default function AdvertisePage() {
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const MySwal = withReactContent(Swal);
const [refresh, setRefresh] = useState(false);
const [placement, setPlacement] = useState("banner");
const [files, setFiles] = useState<File[]>([]);
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", url: "" },
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
maxFiles: 1,
accept:
placement === "banner"
? {
"image/*": [],
"video/*": [],
}
: { "image/*": [] },
});
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
control,
handleSubmit,
formState: { errors },
} = useForm<UserSettingSchema>(formOptions);
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
loading();
const payload = {
Title: values.title,
Description: values.description,
Placement: placement,
RedirectLink: values.url,
};
const res = await createAdvertise(payload);
if (res?.error) {
error(res?.message);
return false;
}
const idNow = res?.data?.data?.id;
if (files.length > 0 && idNow) {
const formFiles = new FormData();
formFiles.append("file", files[0]);
const resFile = await createMediaFileAdvertise(idNow, formFiles);
if (resFile?.error) {
error(resFile?.message);
return false;
}
}
close();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
setRefresh(!refresh);
}
});
};
const handleRemoveFile = (file: File) => {
const uploadedFiles = files;
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
setFiles([...filtered]);
};
return (
<div className="overflow-x-hidden overflow-y-scroll">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
<Button
size="default"
className="bg-[#F07C00] text-white w-full lg:w-fit"
onClick={onOpen}
>
Buat Baru
<AddIcon />
</Button>
<AdvertiseTable triggerRefresh={refresh} />
</div>
</div>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Advertise</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1">
<p className="text-sm">Judul</p>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<Input
id="title"
value={value}
onChange={onChange}
className="w-full border rounded-lg"
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm">{errors.title?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<Textarea
id="description"
value={value}
onChange={onChange}
className="w-full border rounded-lg"
/>
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm">
{errors.description?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Link</p>
<Controller
control={control}
name="url"
render={({ field: { onChange, value } }) => (
<Input
id="url"
value={value}
onChange={onChange}
className="w-full border rounded-lg"
/>
)}
/>
{errors?.url && (
<p className="text-red-400 text-sm">{errors.url?.message}</p>
)}
</div>
<p className="text-sm mt-3">Penempatan</p>
<RadioGroup
value={placement}
onValueChange={setPlacement}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="banner" id="banner" />
<Label htmlFor="banner">Banner</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="jumbotron" id="jumbotron" />
<Label htmlFor="jumbotron">Jumbotron</Label>
</div>
</RadioGroup>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Thumbnail</p>
{files.length < 1 && (
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon />
<h4 className="text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
Tarik file disini atau klik untuk upload.
</h4>
<div className="text-xs text-muted-foreground">
(Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
maksimal 100mb.)
</div>
</div>
</div>
)}
{files.length > 0 && (
<div className="flex flex-row gap-2">
<Image
src={URL.createObjectURL(files[0])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleRemoveFile(files[0])}
>
<TimesIcon />
</Button>
</div>
)}
</div>
<DialogFooter className="pt-4">
<Button type="submit">Simpan</Button>
<Button variant="ghost" onClick={onClose}>
Tutup
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,9 @@
import CreateArticleForm from "@/components/form/article/create-article-form";
export default function CreateArticle() {
return (
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto">
<CreateArticleForm />
</div>
);
}

View File

@ -0,0 +1,22 @@
import EditArticleForm from "@/components/form/article/edit-article-form";
export default function DetailArticlePage() {
return (
<div className="">
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
<div className="flex flex-col gap-1 py-2">
<h1 className="font-bold text-[25px]">Article</h1>
<p className="text-[14px]">Article</p>
</div>
<span className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
</svg>
</span>
</div> */}
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
<EditArticleForm isDetail={true} />
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
import EditArticleForm from "@/components/form/article/edit-article-form";
export default function UpdateArticlePage() {
return (
<div>
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
<div className="flex flex-col gap-1 py-2">
<h1 className="font-bold text-[25px]">Article</h1>
<p className="text-[14px]">Article</p>
</div>
<span className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
</svg>
</span>
</div> */}
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
<EditArticleForm isDetail={false} />
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import ArticleTable from "@/components/table/article-table";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import Link from "next/link";
export default function BasicPage() {
return (
<div>
{/* <div className="flex flex-row justify-between border-b-2 mb-4 px-4 ">
<div className="flex flex-col gap-1 py-2">
<h1 className="font-bold text-[25px]">Article</h1>
<p className="text-[14px]">Article</p>
</div>
<span className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
</svg>
</span>
</div> */}
<div className="overflow-x-hidden overflow-y-scroll w-full">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
<Link href="/admin/article/create">
<Button className="bg-[#F07C00] text-white w-full lg:w-fit hover:bg-[#d96e00]">
Tambah Artikel
<Plus className="ml-2 h-4 w-4" />
</Button>
</Link>
<ArticleTable />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import DashboardContainer from "@/components/main/dashboard/dashboard-container";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
export default function AdminPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<motion.div
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="p-6">
<DashboardContainer />
</div>
</motion.div>
);
}

View File

@ -0,0 +1,7 @@
"use client";
import { AdminLayout } from "@/components/layout/admin-layout";
export default function AdminPageLayout({ children }: { children: React.ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}

View File

@ -0,0 +1,24 @@
"use client";
import MagazineTable from "@/components/table/magazine/magazine-table";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import Link from "next/link";
export default function MagazineTablePage() {
return (
<div className="overflow-x-hidden overflow-y-scroll">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3 w-full">
<Link href="/admin/magazine/create">
<Button className="bg-[#F07C00] text-white hover:bg-[#d96e00] w-full lg:w-auto">
<Plus className="ml-2 h-4 w-4" />
Tambah Majalah
</Button>
</Link>
<MagazineTable />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,341 @@
"use client";
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
import CategoriesTable from "@/components/table/master-categories/categories-table";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { useEffect, useState } from "react";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { useDropzone } from "react-dropzone";
import { close, error, loading } from "@/config/swal";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import Image from "next/image";
import { getArticleByCategory } from "@/service/article";
import {
createCategory,
uploadCategoryThumbnail,
} from "@/service/master-categories";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import useDisclosure from "@/components/useDisclosure";
const createArticleSchema = z.object({
title: z.string().min(2, {
message: "Judul harus diisi",
}),
description: z.string().min(2, {
message: "Deskripsi harus diisi",
}),
tags: z.array(z.string()),
// parent: z.array(categorySchema).nonempty({
// message: "Kategori harus memiliki setidaknya satu item",
// }),
// tags: z.array(z.string()).nonempty({
// message: "Minimal 1 tag",
// }),
});
interface CategoryType {
id: number;
label: string;
value: number;
}
export default function MasterCategoryTable() {
const MySwal = withReactContent(Swal);
const animatedComponents = makeAnimated();
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [refresh, setRefresh] = useState(false);
const [tag, setTag] = useState("");
const [selectedParent, setSelectedParent] = useState<any>();
const [isDetail] = useState<any>();
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", tags: [] },
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
maxFiles: 1,
});
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
getValues,
setError,
clearErrors,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
fetchCategory();
}, []);
const fetchCategory = async () => {
const res = await getArticleByCategory();
if (res?.data?.data) {
setupCategory(res?.data?.data);
}
};
const setupCategory = (data: any) => {
const temp = [];
for (const element of data) {
temp.push({
id: element.id,
label: element.title,
value: element.id,
});
}
setListCategory(temp);
};
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
console.log("values,", values);
loading();
const formData = {
title: values.title,
statusId: 1,
parentId: selectedParent ? selectedParent.id : 0,
tags: values.tags.join(","),
description: values.description,
};
const response = await createCategory(formData);
if (response?.error) {
error(response.message);
return false;
}
const categoryId = response?.data?.data?.id;
const formFiles = new FormData();
formFiles.append("files", files[0]);
const resFile = await uploadCategoryThumbnail(categoryId, formFiles);
if (resFile?.error) {
error(resFile.message);
return false;
}
close();
setRefresh(!refresh);
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
}
});
};
const handleRemoveFile = (file: File) => {
const uploadedFiles = files;
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
setFiles([...filtered]);
};
return (
<div className="overflow-x-hidden overflow-y-scroll">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
<Button
size="default"
className="bg-[#F07C00] text-white w-full lg:w-fit flex items-center gap-2"
onClick={onOpen}
>
Tambah Kategori
<AddIcon />
</Button>
<CategoriesTable triggerRefresh={refresh} />
</div>
</div>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>Kategori Baru</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1">
<p className="text-sm">Nama Kategori</p>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<Input
id="title"
value={value}
onChange={onChange}
readOnly={isDetail}
className="w-full border rounded-lg"
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm">{errors.title?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<Textarea
id="description"
value={value}
onChange={onChange}
readOnly={isDetail}
className="w-full border rounded-lg"
/>
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm">
{errors.description?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Parent</p>
<ReactSelect
className="basic-single text-black z-50"
classNames={{
control: () =>
"!rounded-lg bg-white !border !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
value={selectedParent}
isDisabled={isDetail}
onChange={setSelectedParent}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable
isSearchable
isMulti={false}
placeholder="Kategori..."
name="sub-module"
options={listCategory}
/>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Tag Terkait</p>
<Controller
control={control}
name="tags"
render={({ field: { value } }) => (
<Input
id="tags"
value={tag}
onChange={(e) => setTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (tag.trim() !== "") {
setValue("tags", [...value, tag.trim()]);
setTag("");
e.preventDefault();
}
}
}}
className="w-full border rounded-lg"
/>
)}
/>
<div className="flex flex-wrap gap-1 mt-2">
{getValues("tags")?.map((item, index) => (
<div
key={index}
className="bg-blue-500 text-white px-2 py-1 rounded-full text-sm flex items-center gap-1"
>
{item}
<button
type="button"
onClick={() => {
const filteredTags = getValues("tags").filter(
(tag) => tag !== item
);
if (filteredTags.length === 0) {
setError("tags", {
type: "manual",
message: "Tags tidak boleh kosong",
});
} else {
clearErrors("tags");
setValue("tags", filteredTags);
}
}}
>
&times;
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Thumbnail</p>
{files.length < 1 && (
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex flex-col items-center">
<CloudUploadIcon />
<h4 className="text-2xl font-medium mb-1 mt-3 text-muted-foreground">
Tarik file disini atau klik untuk upload.
</h4>
<div className="text-xs text-muted-foreground">
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
maksimal 100mb.)
</div>
</div>
</div>
)}
{files.length > 0 && (
<div className="flex flex-row gap-2">
<Image
src={URL.createObjectURL(files[0])}
className="w-[30%]"
alt="thumbnail"
width={480}
height={480}
/>
<Button
variant="outline"
onClick={() => handleRemoveFile(files[0])}
>
<TimesIcon />
</Button>
</div>
)}
</div>
<DialogFooter className="self-end">
{!isDetail && <Button type="submit">Simpan</Button>}
<Button variant="outline" onClick={onClose}>
Tutup
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,10 @@
import FormMasterUser from "@/components/form/form-master-user";
import { Card } from "@/components/ui/card";
export default function CreateMasterUserPage() {
return (
<Card className="h-[96vh] rounded-md bg-transparent">
<FormMasterUser />
</Card>
);
}

View File

@ -0,0 +1,10 @@
import FormMasterUserEdit from "@/components/form/form-master-user-edit";
import { Card } from "@/components/ui/card";
export default function CreateMasterUserPage() {
return (
<Card className="h-[96vh] rounded-md bg-transparent">
<FormMasterUserEdit />
</Card>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { AddIcon } from "@/components/icons";
import MasterUserTable from "@/components/table/master-user-table";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function MasterUserPage() {
return (
<div className="overflow-x-hidden overflow-y-scroll">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl py-3">
<Link href="/admin/master-user/create" className="mx-3">
<Button
size="default"
color="primary"
className="bg-[#F07C00] text-white"
>
Pengguna Baru
<AddIcon />
</Button>
</Link>
<MasterUserTable />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import StaticPageBuilder from "@/components/main/static-page/static-page-main";
export default function StaticPageGenerator() {
return (
<div className="bg-transparent p-4 overflow-auto">
<StaticPageBuilder />
</div>
);
}

View File

@ -0,0 +1,10 @@
import StaticPageBuilderEdit from "@/components/form/static-page/static-page-edit-form";
import { Card } from "@/components/ui/card";
export default function StaticPageEdit() {
return (
<Card className="rounded-md bg-transparent p-4 overflow-auto">
<StaticPageBuilderEdit />
</Card>
);
}

View File

@ -0,0 +1,22 @@
import { AddIcon } from "@/components/icons";
import StaticPageTable from "@/components/table/static-page-table";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function StaticPageGeneratorList() {
return (
<div className="overflow-x-hidden overflow-y-scroll rounded-lg">
<div className="px-2 md:px-4 md:py-4 w-full">
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
<Link href="/admin/static-page/create">
<Button size="default" color="primary" className="bg-[#F07C00] text-white w-full lg:w-fit">
Tambah Halaman
<AddIcon />
</Button>
</Link>
<StaticPageTable />
</div>
</div>
</div>
);
}

7
app/auth/layout.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <> {children}</>;
}

10
app/auth/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import Login from "@/components/form/login";
import React from "react";
export default function AuthPage() {
return (
<>
<Login />
</>
);
}

View File

@ -0,0 +1,21 @@
import CitizenNews from "@/components/landing-page/citizen-news/citizen-news";
import HeaderCitizen from "@/components/landing-page/citizen-news/header-citizen";
import Footer from "@/components/landing-page/footer";
import Navbar from "@/components/landing-page/navbar";
import Image from "next/image";
export default function Development() {
return (
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
<div className="relative z-10 bg-[#F2F4F3] max-w-7xl mx-auto">
<Navbar />
<div className="flex-1">
<HeaderCitizen />
<CitizenNews />
</div>
<Footer />
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import DevelopmentNews from "@/components/landing-page/development/development-news";
import HeaderDevelopment from "@/components/landing-page/development/header-development";
import Footer from "@/components/landing-page/footer";
import Navbar from "@/components/landing-page/navbar";
import Image from "next/image";
export default function Development() {
return (
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
<div className="relative z-10 bg-[#F2F4F3] max-w-7xl mx-auto">
<Navbar />
<div className="flex-1">
<HeaderDevelopment />
<DevelopmentNews />
</div>
<Footer />
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import Footer from "@/components/landing-page/footer";
import HealthNews from "@/components/landing-page/health/health-news";
import HeaderHealth from "@/components/landing-page/health/header-health";
import Navbar from "@/components/landing-page/navbar";
import Image from "next/image";
export default function Development() {
return (
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
<div className="relative z-10 bg-[#F2F4F3] max-w-7xl mx-auto">
<Navbar />
<div className="flex-1">
<HeaderHealth />
<HealthNews />
</div>
<Footer />
</div>
</div>
);
}

19
app/detail/[id]/page.tsx Normal file
View File

@ -0,0 +1,19 @@
import DetailContent from "@/components/details/details-content";
import Footer from "@/components/landing-page/footer";
import Navbar from "@/components/landing-page/navbar";
import Image from "next/image";
export default function Home() {
return (
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
<div className="relative z-10 bg-[#F2F4F3] max-w-7xl mx-auto">
<Navbar />
<div className="flex-1">
<DetailContent />
</div>
<Footer />
</div>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
app/globals.css Normal file
View File

@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

34
app/layout.tsx Normal file
View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

17
app/page.tsx Normal file
View File

@ -0,0 +1,17 @@
import Footer from "@/components/landing-page/footer";
import Header from "@/components/landing-page/header";
import Navbar from "@/components/landing-page/navbar";
import Beranda from "@/components/landing-page/news";
export default function Home() {
return (
<div className="flex min-h-screen flex-col font-[family-name:var(--font-geist-sans)] bg-white">
<Navbar />
<div className="flex-1">
<Header />
</div>
<Beranda />
<Footer />
</div>
);
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,103 @@
"use client";
import Image from "next/image";
import { useState } from "react";
const allArticles = Array.from({ length: 20 }, (_, i) => ({
title: [
"Operasi Keselamatan Musi 2024: Ditlantas Polda Sumsel Himbau Keselamatan de...",
"Gelar Operasi Semeru, Kanit Lantas Gencarkan Sosialisasi Keselamatan Berken...",
"Kapolsek Sooko sambang dunia pendidikan berikan himbauan kamtibmas di MTS S...",
"PATROLI DIALOGIS POLSEK GONDANG TINGKATKAN KEWASPADAAN SEKITAR KEPADA SATPAM",
][i % 4],
date: "07-03-2024, 07:55",
image: `/images/article${(i % 4) + 1}.jpg`,
}));
const articlesPerPage = 4;
const totalPages = Math.ceil(allArticles.length / articlesPerPage);
export default function DashboardRecentArticles() {
const [currentPage, setCurrentPage] = useState(1);
const paginatedArticles = allArticles.slice(
(currentPage - 1) * articlesPerPage,
currentPage * articlesPerPage
);
return (
<div className="p-4 bg-white rounded-lg shadow-md">
{/* Header */}
<div className="flex justify-between items-center mb-4">
<h2 className="font-semibold text-base">Recent Article</h2>
<button className="px-4 py-1 border border-blue-500 text-blue-500 rounded-full hover:bg-blue-50 text-sm">
Buat Article
</button>
</div>
{/* Article List */}
<ul className="space-y-4">
{paginatedArticles.map((article, index) => (
<li key={index} className="flex items-start space-x-3">
<Image
src={article.image}
alt={article.title}
className="w-16 h-16 rounded object-cover"
/>
<div className="flex-1">
<h3 className="text-sm font-medium leading-snug line-clamp-2">
{article.title}
</h3>
<p className="text-xs text-gray-500 mt-1">{article.date}</p>
</div>
</li>
))}
</ul>
{/* Pagination */}
<div className="flex items-center justify-center mt-6 space-x-1 text-sm">
<button
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="px-2 py-1 rounded text-gray-600"
disabled={currentPage === 1}
>
&lt;
</button>
{[...Array(totalPages).keys()].slice(0, 5).map((_, idx) => {
const page = idx + 1;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-3 py-1 rounded-full ${
currentPage === page
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700"
}`}
>
{page}
</button>
);
})}
<span className="px-2 py-1 text-gray-400">...</span>
<button
onClick={() => setCurrentPage(totalPages)}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full"
>
{totalPages}
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
className="px-2 py-1 rounded text-gray-600"
disabled={currentPage === totalPages}
>
&gt;
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { Icon } from "@iconify/react";
export default function DashboardStats() {
const statsData = [
{
type: "profile",
name: "test akun",
role: "admin-mabes",
todayPosts: 0,
weekPosts: 0,
icon: "mdi:account-tie",
},
{
label: "Total post",
value: 2363,
icon: "mdi:post-outline",
},
{
label: "Total views",
value: 80,
icon: "ic:baseline-star-rate",
},
{
label: "Total share",
value: 1,
icon: "mdi:share",
},
{
label: "Total comment",
value: 1,
icon: "mdi:comment-outline",
},
];
return (
<div className="p-4 flex flex-col lg:flex-row justify-between gap-4">
{statsData.map((item, index) => {
if (item.type === "profile") {
return (
<div key={index} className="bg-white w-full lg:w-[25%] shadow-md rounded-lg p-4 flex flex-col justify-between col-span-1 sm:col-span-2">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">{item.name}</p>
<p className="text-sm text-gray-500">{item.role}</p>
</div>
<Icon icon={item.icon} className="text-[60px] text-black" />
</div>
<div className="mt-4 text-sm text-black space-y-1 flex flex-row gap-2">
<p>
<span className="text-black font-bold text-[18px]">{item.todayPosts} Post</span> Hari ini
</p>
<p>
<span className="text-black font-bold text-[18px]">{item.weekPosts} Post</span> Minggu ini
</p>
</div>
</div>
);
}
return (
<div key={index} className="bg-white shadow-md rounded-lg p-4 flex flex-col w-full lg:w-[18%] items-center justify-center">
<Icon icon={item.icon} className="text-[45px] text-black mb-2" />
<p className="text-sm text-gray-500">{item.label}</p>
<p className="text-xl font-bold">{item.value}</p>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,52 @@
export default function PostRecapTable() {
const postsData = [
{ name: "POLDA SUMUT", posts: 5304 },
{ name: "SATWIL", posts: 4043 },
{ name: "POLDA JATENG", posts: 3482 },
{ name: "POLDA JATIM", posts: 3138 },
{ name: "POLDA SUMSEL", posts: 2677 },
{ name: "POLDA JABAR", posts: 1677 },
{ name: "POLDA KALTIM", posts: 1565 },
{ name: "POLDA RIAU", posts: 1192 },
{ name: "POLDA KALBAR", posts: 920 },
{ name: "POLDA SULBAR", posts: 730 },
{ name: "POLDA METRO JAYA", posts: 707 },
{ name: "POLDA BALI", posts: 580 },
{ name: "POLDA SULTRA", posts: 375 },
{ name: "POLDA MALUKU", posts: 373 },
{ name: "POLDA BABEL", posts: 344 },
];
return (
<div className="p-4 bg-white shadow-md rounded-lg w-full lg:w-[55%]">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<h2 className="font-semibold text-xs sm:text-sm">Rekapitulasi Post Berita Polda/Polres Pada Website</h2>
<p className="text-xs mt-2 sm:mt-0 sm:text-right">
<span className="font-semibold">01-05-2025</span> - <span className="font-semibold">08-05-2025</span>
</p>
</div>
{/* Scrollable container */}
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded">
<table className="min-w-full text-sm text-left">
<thead className="text-black sticky top-0 z-10 bg-white">
<tr>
<th className="border px-4 py-2 w-10">NO</th>
<th className="border px-4 py-2">POLDA/POLRES</th>
<th className="border px-4 py-2 text-right">JUMLAH POST BERITA</th>
</tr>
</thead>
<tbody className="text-gray-700">
{postsData.map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="border px-4 py-2 text-center">{index + 1}</td>
<td className="border px-4 py-2">{item.name}</td>
<td className="border px-4 py-2 text-right">{item.posts}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,928 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getArticleById, getListArticle } from "@/service/article";
import { close, error, loading } from "@/config/swal";
import { useParams, usePathname } from "next/navigation";
import { Link2, MailIcon } from "lucide-react";
import { getAdvertise } from "@/service/advertisement";
import { saveActivity } from "@/service/activity-log";
import {
getArticleComment,
otpRequest,
otpValidation,
postArticleComment,
} from "@/service/master-user";
import { useForm } from "react-hook-form";
type TabKey = "trending" | "comments" | "latest";
type Article = {
id: number;
title: string;
description: string;
htmlDescription: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
interface CategoryType {
id: number;
label: string;
value: number;
}
type Advertise = {
id: number;
title: string;
description: string;
placement: string;
contentFileUrl: string;
redirectLink: string;
};
export default function DetailContent() {
const params = useParams();
const id = params?.id;
const pathname = usePathname();
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [articleDetail, setArticleDetail] = useState<any>(null);
const [showData, setShowData] = useState("5");
const [search, setSearch] = useState("-");
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [selectedCategories, setSelectedCategories] = useState<any>("-");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const [detailfiles, setDetailFiles] = useState<any>([]);
const [mainImage, setMainImage] = useState(0);
const [thumbnail, setThumbnail] = useState("-");
const [diseId, setDiseId] = useState(0);
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(
null
);
const [selectedIndex, setSelectedIndex] = useState(0);
const [tabArticles, setTabArticles] = useState<Article[]>([]);
const [activeTab, setActiveTab] = useState<TabKey>("trending");
const [needOtp, setNeedOtp] = useState(false);
const [otpValue, setOtpValue] = useState("");
const { register, handleSubmit, reset, watch } = useForm();
const [commentList, setCommentList] = useState<any>([]);
useEffect(() => {
fetchData();
}, [id]);
const fetchData = async () => {
try {
const res = await getArticleComment(String(id));
const data = res?.data?.data;
setCommentList(data);
console.log("komen", data);
} catch (err) {
console.error("❌ Gagal memuat komentar:", err);
setCommentList([]);
}
};
const onSubmit = async (values: any) => {
if (!needOtp) {
const res = await otpRequest(values.email, values?.name);
if (res?.error) {
error(res.message);
return false;
}
setNeedOtp(true);
} else {
const validation = await otpValidation(values.email, otpValue);
if (validation?.error) {
error("OTP Tidak Sesuai");
return false;
}
const data = {
commentFromName: values.name,
commentFromEmail: values.email,
articleId: Number(id),
isPublic: false,
message: values.comment,
parentId: 0,
};
const res = await postArticleComment(data);
if (res?.error) {
error(res?.message);
return false;
}
const req: any = {
activityTypeId: 5,
url: "https://dev.mikulnews/" + pathname,
articleId: Number(id),
};
const resActivity = await saveActivity(req);
reset();
fetchData();
setNeedOtp(false);
}
};
const tabs: { id: TabKey; label: string }[] = [
{ id: "trending", label: "Trending" },
{ id: "comments", label: "Comments" },
{ id: "latest", label: "Latest" },
];
const [bannerAd, setBannerAd] = useState<Advertise | null>(null);
useEffect(() => {
initStateAdver();
}, []);
async function initStateAdver() {
const req = {
limit: 100,
page: 1,
sort: "desc",
sortBy: "created_at",
isPublish: true,
};
try {
const res = await getAdvertise(req);
const data: Advertise[] = res?.data?.data || [1];
const banner = data.find((ad) => ad.placement === "jumbotron");
if (banner) {
setBannerAd(banner);
}
} catch (err) {
console.error("Error fetching advertisement:", err);
}
}
useEffect(() => {
fetchTabArticles();
}, [activeTab]);
async function fetchTabArticles() {
const req: any = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
sortBy: "created_at",
};
if (activeTab === "trending") {
req.sortBy = "view_count";
} else if (activeTab === "comments") {
req.sortBy = "comment_count";
} else {
req.sortBy = "created_at";
}
if (search && search !== "-" && search !== "") {
req.search = search;
}
if (selectedCategories && selectedCategories !== "-") {
req.categorySlug = selectedCategories;
}
try {
const res = await getListArticle(req);
setTabArticles(res?.data?.data || []);
} catch (error) {
console.error("Failed fetching tab articles:", error);
setTabArticles([]); // Optional fallback
}
}
const content: Record<
TabKey,
{ image: string; title: string; date: string }[]
> = {
trending: [
{
image: "/thumb1.png",
title:
"#StopBullyDiSekolah: Peran Positif Media Sosial dalam Mengatasi Bullying",
date: "22 FEBRUARI 2024",
},
{
image: "/thumb2.png",
title:
"Polri Gelar Lomba Orasi Unjuk Rasa dalam Rangka Hari HAM Sedunia Berhadiah Total Lebih dari Rp 150 juta!",
date: "29 NOVEMBER 2021",
},
{
image: "/thumb3.png",
title: "Tingkatkan Ibadah Sambut #RamadhanPenuhDamai",
date: "7 MARET 2024",
},
{
image: "/thumb4.png",
title:
"Exploring the Charm of Papuas Traditional Clothing: A Captivating and Meaningful Cultural Heritage",
date: "1 AGUSTUS 2024",
},
],
comments: [
{
image: "/thumb-comment.png",
title: "Pengunjung Komentar Positif tentang Fitur Baru",
date: "3 JUNI 2024",
},
],
latest: [
{
image: "/thumb-latest.png",
title: "Update Terbaru dari Redaksi Hari Ini",
date: "2 JULI 2025",
},
],
};
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories]);
async function initState() {
// loading();
const req = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
useEffect(() => {
initStateData();
}, [listCategory]);
async function initStateData() {
loading();
const res = await getArticleById(id);
const data = res?.data?.data;
setThumbnail(data?.thumbnailUrl);
setDiseId(data?.aiArticleId);
setDetailFiles(data?.files);
setArticleDetail(data); // <-- Add this
close();
}
// if (!articleDetail?.files || articleDetail?.files?.length === 0) {
// return (
// <div className="w-full h-[400px] bg-gray-100 flex items-center justify-center rounded-lg">
// <p className="text-gray-400 text-sm">Gambar tidak tersedia</p>
// </div>
// );
// }
function decodeHtmlString(raw: string = "") {
if (!raw) return "";
// 1⃣ Hapus newline escape, backslash, dsb
let decoded = raw
.replace(/\\n/g, "\n")
.replace(/\\"/g, '"') // ubah \" jadi "
.replace(/\\'/g, "'") // ubah \' jadi '
.replace(/\\\\/g, "\\") // ubah \\ jadi \
.trim();
// 2⃣ Decode entity HTML (misal &quot;)
const el = document.createElement("textarea");
el.innerHTML = decoded;
return el.value;
}
return (
<>
<div className="bg-white grid grid-cols-1 md:grid-cols-3 gap-6 px-8 py-8">
<div className="md:col-span-2">
<p className="text-sm text-gray-500 mb-2">Home {">"}Detail</p>
<h1 className="text-3xl md:text-4xl font-bold text-[#1a1a1a] leading-tight mb-4">
{articleDetail?.title}
</h1>
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-4">
<div className="text-blue-500">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
// fill-rule="evenodd"
>
<path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2M8.5 9.5a3.5 3.5 0 1 1 7 0a3.5 3.5 0 0 1-7 0m9.758 7.484A7.99 7.99 0 0 1 12 20a7.99 7.99 0 0 1-6.258-3.016C7.363 15.821 9.575 15 12 15s4.637.821 6.258 1.984"
/>
</g>
</svg>
</div>
<span className="text-blue-500 font-medium">
{articleDetail?.customCreatorName || articleDetail?.createdByName}
</span>
<span></span>
<span>
<span>
{new Date(articleDetail?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</span>
</span>
<span></span>
<span>{articleDetail?.categories?.[0]?.title}</span>
</div>
<div className="w-full h-auto mb-6">
{/* Gambar utama */}
{articleDetail?.files && articleDetail.files.length > 0 ? (
<>
{/* Gambar utama */}
<div className="w-full">
<Image
src={articleDetail.files[selectedIndex]?.fileUrl}
alt={
articleDetail.files[selectedIndex]?.fileAlt || "Berita"
}
width={800}
height={400}
className="rounded-lg w-full object-cover"
/>
</div>
{/* Thumbnail */}
<div className="flex gap-2 mt-3 overflow-x-auto">
{articleDetail.files.map((file: any, index: number) => (
<button
key={file?.id || index}
onClick={() => setSelectedIndex(index)}
className={`border-2 rounded-lg overflow-hidden ${
selectedIndex === index
? "border-red-500"
: "border-transparent"
}`}
>
<Image
src={file?.fileUrl}
alt={file?.fileAlt || "Thumbnail"}
width={100}
height={80}
className="object-cover"
/>
</button>
))}
</div>
</>
) : (
// Jika file kosong/null tapi tetap render data lainnya
<div className="w-full h-[400px] bg-gray-100 flex items-center justify-center rounded-lg">
<p className="text-gray-400 text-sm">Gambar tidak tersedia</p>
</div>
)}
{/* Slug */}
<p className="text-sm text-gray-500 mt-2 text-end">
{articleDetail?.slug}
</p>
</div>
<div className="flex relative">
<div className=" flex flex-col w-fit rounded overflow-hidden mr-5">
<Link
href="#"
aria-label="Facebook"
className="bg-[#3b5998] p-4 flex justify-center items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="white"
viewBox="0 0 24 24"
>
<path d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z" />
</svg>
</Link>
<Link
href="#"
aria-label="Twitter"
className="bg-[#55acee] p-4 flex justify-center items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="white"
viewBox="0 0 24 24"
>
<path d="M7.91 20.889c8.302 0 12.845-6.885 12.845-12.845c0-.193 0-.387-.009-.58A9.2 9.2 0 0 0 23 5.121a9.2 9.2 0 0 1-2.597.713a4.54 4.54 0 0 0 1.99-2.5a9 9 0 0 1-2.87 1.091A4.5 4.5 0 0 0 16.23 3a4.52 4.52 0 0 0-4.516 4.516c0 .352.044.696.114 1.03a12.82 12.82 0 0 1-9.305-4.718a4.526 4.526 0 0 0 1.4 6.03a4.6 4.6 0 0 1-2.043-.563v.061a4.524 4.524 0 0 0 3.62 4.428a4.4 4.4 0 0 1-1.189.159q-.435 0-.845-.08a4.51 4.51 0 0 0 4.217 3.135a9.05 9.05 0 0 1-5.608 1.936A9 9 0 0 1 1 18.873a12.84 12.84 0 0 0 6.91 2.016" />
</svg>
</Link>
<Link
href="#"
aria-label="Google"
className="bg-[#fce9e7] p-4 flex justify-center items-center text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M7.796 14.333v-2.618h7.211c.066.382.12.763.12 1.265c0 4.364-2.923 7.462-7.33 7.462A7.63 7.63 0 0 1 .16 12.806a7.63 7.63 0 0 1 7.636-7.637c2.062 0 3.786.753 5.117 1.997L10.84 9.162c-.567-.546-1.56-1.178-3.044-1.178c-2.607 0-4.734 2.16-4.734 4.822s2.127 4.821 4.734 4.821c3.022 0 4.157-2.17 4.331-3.294zm13.27-2.6H23.2v2.134h-2.133V16h-2.134v-2.133H16.8v-2.134h2.133V9.6h2.134z" />
</svg>
</Link>
<Link
href="#"
aria-label="Share"
className="bg-[#cccccc] p-4 flex justify-center items-center text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="m21 12l-7-7v4C7 10 4 15 3 20c2.5-3.5 6-5.1 11-5.1V19z" />
</svg>
</Link>
</div>
<div className="flex-1 overflow-y-auto">
<div className="prose max-w-none text-justify">
<div
dangerouslySetInnerHTML={{
__html: decodeHtmlString(
articleDetail?.htmlDescription || ""
),
}}
/>
</div>
{/* <Author /> */}
<div className="w-full bg-white py-6">
<p className="mx-10 text-2xl mb-4 ">AUTHOR</p>
<div className=" border border-black p-6 flex items-center gap-6 max-w-[1200px] mx-auto">
{/* Foto Profil */}
<div className="w-20 h-20 relative ">
<Image
src="/profile.jpg"
alt="Author"
fill
className="rounded-full object-cover"
/>
</div>
{/* Info Author */}
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-800">
{articleDetail?.customCreatorName ||
articleDetail?.createdByName}
</h3>
<div className="mt-2 flex items-center gap-4 flex-wrap">
{/* Button lihat semua post */}
<button className="text-sm font-medium text-white hover:underline bg-[#655997] py-1 px-5 rounded-xl">
Lihat Semua Pos
</button>
<div className="bg-[#655997] rounded-full p-1">
<MailIcon
size={18}
className="text-white hover:text-black cursor-pointer "
></MailIcon>
</div>
<div className="bg-[#655997] rounded-full p-1">
<Link2
size={18}
className="text-white hover:text-black cursor-pointer "
></Link2>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-row gap-2 items-center">
<span className="font-semibold text-sm text-gray-700">
Tags:
</span>
<div className="flex flex-wrap gap-2 mt-1">
{articleDetail?.tags ? (
<span className="bg-gray-100 text-gray-700 text-sm px-2 py-1 rounded">
{articleDetail.tags}
</span>
) : (
<span className="text-sm text-gray-500">Tidak ada tag</span>
)}
</div>
</div>
</div>
</div>
{/* <div className="relative mb-2 h-[120px] overflow-hidden flex items-center border my-8">
<Image
src={"/image-kolom.png"}
alt="Berita Utama"
fill
className="object-contain"
/>
</div> */}
<div className="mt-10">
{/* <div className="flex items-center space-x-4 p-4 border rounded-lg mb-6">
<Image
src={"/author.png"}
alt="Author"
width={60}
height={60}
className="rounded-full"
/>
<div>
<p className="text-blue-600 font-bold text-lg">
christine natalia
</p>
</div>
</div> */}
<h2 className="text-2xl font-bold mb-2">Tinggalkan Balasan</h2>
<p className="text-gray-600 mb-4 text-sm">
Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib
ditandai <span className="text-blue-600">*</span>
</p>
<div>
<h3 className="text-lg font-semibold border-b pb-2">Komentar</h3>
{commentList?.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada komentar.</p>
) : (
commentList?.map((comment: any) => (
<div
key={comment?.id}
className="border rounded-lg p-3 bg-gray-50 shadow-sm"
>
<p className="text-sm text-gray-800 whitespace-pre-line">
{comment?.message}
</p>
<p className="text-xs text-gray-500 mt-1">
{comment?.commentFromName || "Anonim"} {" "}
{new Date(comment?.createdAt).toLocaleString("id-ID", {
dateStyle: "short",
timeStyle: "short",
})}
</p>
</div>
))
)}
</div>
<form className="space-y-6 mt-6" onSubmit={handleSubmit(onSubmit)}>
{!needOtp ? (
<>
{/* Komentar */}
<div>
<label
htmlFor="komentar"
className="block text-sm font-medium mb-1"
>
Komentar <span className="text-blue-600">*</span>
</label>
<textarea
id="komentar"
className="w-full border border-gray-300 rounded-md p-3 h-40 focus:outline-none focus:ring-2 focus:ring-blue-600"
{...register("comment", { required: true })}
/>
</div>
{/* Nama */}
<div>
<label
htmlFor="nama"
className="block text-sm font-medium mb-1"
>
Nama <span className="text-blue-600">*</span>
</label>
<input
type="text"
id="nama"
className="w-full border border-gray-300 rounded-md p-2"
{...register("name", { required: true })}
/>
</div>
{/* Email */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium mb-1"
>
Email <span className="text-blue-600">*</span>
</label>
<input
type="email"
id="email"
className="w-full border border-gray-300 rounded-md p-2"
{...register("email", { required: true })}
/>
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-2 rounded-md transition mt-2 w-full"
>
KIRIM KOMENTAR
</button>
</>
) : (
<>
<p className="text-sm text-gray-600">
Kode verifikasi sudah dikirimkan. Silakan cek Email Anda!
</p>
<div>
<label className="block text-sm font-medium mb-1 mt-4">
OTP
</label>
<div className="flex gap-2 justify-center">
{[...Array(6)].map((_, i) => (
<input
key={i}
type="text"
maxLength={1}
className="w-10 h-10 text-center border border-gray-300 rounded-md text-lg"
value={otpValue[i] || ""}
onChange={(e) => {
const newValue = otpValue.split("");
newValue[i] = e.target.value.replace(/[^0-9]/g, "");
setOtpValue(newValue.join(""));
}}
/>
))}
</div>
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-2 rounded-md transition mt-4 w-full"
>
Kirim
</button>
</>
)}
</form>
</div>
</div>
<div className="md:col-span-1 space-y-6">
<div className="sticky top-0 space-y-6">
<div className="bg-white shadow p-4 rounded-lg">
{bannerAd ? (
<a
href={bannerAd.redirectLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full"
>
<div className="relative w-full h-[350px] flex justify-center">
<Image
src={bannerAd.contentFileUrl}
alt={bannerAd.title || "Iklan Banner"}
width={1200} // ukuran dasar untuk responsive
height={350}
className="object-cover w-full h-full"
/>
</div>
</a>
) : (
<Image
src="/kolom.png"
alt="Berita Utama"
width={1200}
height={188}
className="object-contain w-full h-[188px]"
/>
)}
<button className="mt-4 w-full bg-black text-white py-2 rounded hover:opacity-90">
Learn More
</button>
</div>
<div className="bg-white shadow p-4 rounded-lg">
<h2 className="text-lg font-semibold mb-2">Connect with us</h2>
<div className="flex space-x-2">
<div className="bg-[#0057ff] text-white px-3 py-2 rounded">
<p className="text-sm font-bold"></p>
<p className="text-xs">139 Followers</p>
</div>
<div className="bg-[#ff0000] text-white px-3 py-2 rounded">
<p className="text-sm font-bold">YouTube</p>
<p className="text-xs">205k Subscribers</p>
</div>
<div className="bg-[#f9a825] text-white px-3 py-2 rounded">
<p className="text-sm font-bold">RSS</p>
<p className="text-xs">23.9k Followers</p>
</div>
</div>
</div>
<div className="bg-white shadow p-4 rounded-lg">
<div className="flex space-x-4 border-b mb-4">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-2 text-sm font-medium ${
activeTab === tab.id
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="space-y-4">
{tabArticles.map((item, idx) => (
<div key={idx} className="flex space-x-3">
<Image
src={item.thumbnailUrl || "/default-thumb.png"}
alt={item.title}
width={70}
height={70}
className="rounded w-[70px] h-[70px] object-cover"
/>
<div>
<p className="text-sm font-semibold leading-snug">
{item.title}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(item.createdAt).toLocaleDateString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
</div>
</div>
))}
</div>
{tabArticles.length === 0 ? (
<p className="text-sm text-gray-500">
Artikel tidak ditemukan.
</p>
) : (
tabArticles.map((item, idx) => (
<div key={idx} className="flex space-x-3">
<Image
src={item.thumbnailUrl || "/default-thumb.png"}
alt={item.title}
width={70}
height={70}
className="rounded w-[70px] h-[70px] object-cover"
/>
<div>
<p className="text-sm font-semibold leading-snug">
{item.title}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(item.createdAt).toLocaleDateString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
</div>
</div>
))
)}
</div>
<div className="mt-6">
<h3 className="text-base font-semibold mb-2 text-gray-800 border-b pb-1 border-blue-600 inline-block">
Recommended
</h3>
<div className="space-y-4">
<div className="relative">
<Image
src={"/gaza.png"}
alt="Recommended Article"
width={400}
height={200}
className="rounded-lg w-full h-auto object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-60 text-white p-3 rounded-b-lg">
<p className="text-sm font-semibold leading-tight">
Bom Bunuh Diri Guncang Gereja di Damaskus, 20 Orang Tewas
dan Puluhan Terluka
</p>
<p className="text-xs text-gray-300 mt-1">
📅 23 JUNI 2025
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex space-x-3">
<Image
src={"/perang.png"}
alt="OPM Serang Gereja"
width={80}
height={60}
className="rounded object-cover w-[80px] h-[60px]"
/>
<div>
<p className="text-sm font-semibold leading-snug">
OPM Mulai Kehilangan Simpati dari Masyarakat Papua Usai
Serang Gereja
</p>
<p className="text-xs text-gray-500 mt-1">
📅 15 JUNI 2025
</p>
</div>
</div>
<div className="flex space-x-3">
<Image
src={"/jateng.png"}
alt="Denda Merokok"
width={80}
height={60}
className="rounded object-cover w-[80px] h-[60px]"
/>
<div>
<p className="text-sm font-semibold leading-snug">
Jakarta Terapkan Denda Rp 250.000 bagi Warga yang
Merokok Sembarangan
</p>
<p className="text-xs text-gray-500 mt-1">
📅 13 JUNI 2025
</p>
</div>
</div>
<div className="flex space-x-3">
<Image
src={"/investasi.jpg"}
alt="Pengguna Internet"
width={80}
height={60}
className="rounded object-cover w-[80px] h-[60px]"
/>
<div>
<p className="text-sm font-semibold leading-snug">
Warga Indonesia Jadi Pengguna Internet via Ponsel
Terbanyak di Dunia
</p>
<p className="text-xs text-gray-500 mt-1">
📅 26 MEI 2025
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

Binary file not shown.

View File

@ -0,0 +1,171 @@
// components/custom-editor.js
import React from "react";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import Editor from "@/vendor/ckeditor5/build/ckeditor";
function CustomEditor(props) {
const maxHeight = props.maxHeight || 600;
return (
<div className="ckeditor-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
onChange={(event, editor) => {
const data = editor.getData();
console.log({ event, editor, data });
props.onChange(data);
}}
config={{
toolbar: [
"heading",
"fontsize",
"bold",
"italic",
"link",
"numberedList",
"bulletedList",
"undo",
"redo",
"alignment",
"outdent",
"indent",
"blockQuote",
"insertTable",
"codeBlock",
"sourceEditing",
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #111 !important;
background: #fff !important;
margin: 0;
padding: 1rem;
}
p {
margin: 0.5em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
color: inherit !important;
}
ul, ol {
margin: 0.5em 0;
padding-left: 2em;
}
blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #d1d5db;
background-color: #f9fafb;
color: inherit !important;
}
`,
height: props.height || 400,
removePlugins: ["Title"],
mobile: {
theme: "silver",
},
}}
/>
<style jsx>{`
.ckeditor-wrapper {
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.ckeditor-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px;
max-height: ${maxHeight}px;
}
.ckeditor-wrapper :global(.ck.ck-editor__editable) {
min-height: ${(props.height || 400) - 50}px;
max-height: ${maxHeight - 50}px;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
background: #fff !important;
color: #111 !important;
}
/* Dark mode support */
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable) {
background: #111 !important;
color: #f9fafb !important;
}
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h1),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h2),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h3),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h4),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h5),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h6) {
color: #f9fafb !important;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable blockquote) {
background-color: #1f2937 !important;
border-left-color: #374151 !important;
color: #f3f4f6 !important;
}
/* Custom scrollbar styling for webkit browsers */
.ckeditor-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar) {
width: 8px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #f1f5f9;
border-radius: 4px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #cbd5e1;
border-radius: 4px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #94a3b8;
}
/* Dark mode scrollbar */
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #1f2937;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #4b5563;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #6b7280;
}
/* Ensure content doesn't overflow */
.ckeditor-wrapper :global(.ck.ck-editor__editable .ck-content) {
overflow: hidden;
}
`}</style>
</div>
);
}
export default CustomEditor;

View File

@ -0,0 +1,164 @@
"use client";
import React, { useState } from 'react';
// Import the optimized editor (choose one based on your migration)
// import OptimizedEditor from './optimized-editor'; // TinyMCE
// import OptimizedCKEditor from './optimized-ckeditor'; // CKEditor5 Classic
// import MinimalEditor from './minimal-editor'; // React Quill
interface EditorExampleProps {
editorType?: 'tinymce' | 'ckeditor' | 'quill';
}
const EditorExample: React.FC<EditorExampleProps> = ({
editorType = 'tinymce'
}) => {
const [content, setContent] = useState('<p>Hello, this is the editor content!</p>');
const [savedContent, setSavedContent] = useState('');
const handleContentChange = (newContent: string) => {
setContent(newContent);
};
const handleSave = () => {
setSavedContent(content);
console.log('Content saved:', content);
};
const handleReset = () => {
setContent('<p>Content has been reset!</p>');
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">Rich Text Editor Example</h2>
<p className="text-gray-600 mb-4">
This is an optimized editor with {editorType} - much smaller bundle size and better performance!
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor Panel */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Editor</h3>
<div className="flex gap-2">
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Reset
</button>
</div>
</div>
<div className="border border-gray-200 rounded-lg">
{/* Choose your editor based on migration */}
{editorType === 'tinymce' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
TinyMCE Editor (200KB bundle)
</p>
{/* <OptimizedEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">TinyMCE Editor Component</p>
</div>
</div>
)}
{editorType === 'ckeditor' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
CKEditor5 Classic (800KB bundle)
</p>
{/* <OptimizedCKEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">CKEditor5 Classic Component</p>
</div>
</div>
)}
{editorType === 'quill' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
React Quill (100KB bundle)
</p>
{/* <MinimalEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">React Quill Component</p>
</div>
</div>
)}
</div>
</div>
{/* Preview Panel */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Preview</h3>
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Current Content:</h4>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
{savedContent && (
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Saved Content:</h4>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: savedContent }}
/>
</div>
)}
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Raw HTML:</h4>
<pre className="text-xs bg-gray-100 p-2 rounded overflow-auto max-h-32">
{content}
</pre>
</div>
</div>
</div>
{/* Performance Info */}
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Performance Benefits:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> 90% smaller bundle size compared to custom CKEditor5</li>
<li> Faster initial load time</li>
<li> Better mobile performance</li>
<li> Reduced memory usage</li>
<li> Improved Lighthouse score</li>
</ul>
</div>
</div>
);
};
export default EditorExample;

View File

@ -0,0 +1,176 @@
"use client";
import React, { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import CustomEditor from './custom-editor';
import FormEditor from './form-editor';
export default function EditorTest() {
const [testData, setTestData] = useState('Initial test content');
const [editorType, setEditorType] = useState('custom');
const { control, setValue, watch, handleSubmit } = useForm({
defaultValues: {
title: 'Test Title',
description: testData,
creatorName: 'Test Creator'
}
});
const watchedValues = watch();
const handleSetValue = () => {
const newContent = `<p>Updated content at ${new Date().toLocaleTimeString()}</p><p>This content was set via setValue</p>`;
setValue('description', newContent);
setTestData(newContent);
};
const handleSetEmpty = () => {
setValue('description', '');
setTestData('');
};
const handleSetHTML = () => {
const htmlContent = `
<h2>HTML Content Test</h2>
<p>This is a <strong>bold</strong> paragraph with <em>italic</em> text.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
<li>List item 3</li>
</ul>
<p>Updated at: ${new Date().toLocaleTimeString()}</p>
`;
setValue('description', htmlContent);
setTestData(htmlContent);
};
const onSubmit = (data: any) => {
console.log('Form submitted:', data);
alert('Form submitted! Check console for data.');
};
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Editor Test Component</h1>
<Card className="p-4">
<div className="space-y-4">
<div>
<Label>Editor Type:</Label>
<div className="flex gap-2 mt-2">
<Button
variant={editorType === 'custom' ? 'default' : 'outline'}
onClick={() => setEditorType('custom')}
>
CustomEditor
</Button>
<Button
variant={editorType === 'form' ? 'default' : 'outline'}
onClick={() => setEditorType('form')}
>
FormEditor
</Button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={handleSetValue} variant="outline">
Set Value (Current Time)
</Button>
<Button onClick={handleSetEmpty} variant="outline">
Set Empty
</Button>
<Button onClick={handleSetHTML} variant="outline">
Set HTML Content
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Current Test Data:</Label>
<div className="mt-2 p-2 bg-gray-100 rounded text-sm">
{testData || '(empty)'}
</div>
</div>
<div>
<Label>Watched Form Values:</Label>
<div className="mt-2 p-2 bg-gray-100 rounded text-sm">
<pre>{JSON.stringify(watchedValues, null, 2)}</pre>
</div>
</div>
</div>
</div>
</Card>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card className="p-4">
<div className="space-y-4">
<div>
<Label>Title:</Label>
<Controller
control={control}
name="title"
render={({ field }) => (
<Input {...field} className="mt-1" />
)}
/>
</div>
<div>
<Label>Description (Editor):</Label>
<Controller
control={control}
name="description"
render={({ field }) => (
editorType === 'custom' ? (
<CustomEditor
onChange={field.onChange}
initialData={field.value}
/>
) : (
<FormEditor
onChange={field.onChange}
initialData={field.value}
/>
)
)}
/>
</div>
<div>
<Label>Creator Name:</Label>
<Controller
control={control}
name="creatorName"
render={({ field }) => (
<Input {...field} className="mt-1" />
)}
/>
</div>
<Button type="submit" className="w-full">
Submit Form
</Button>
</div>
</Card>
</form>
<Card className="p-4">
<h3 className="font-semibold mb-2">Instructions:</h3>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Switch between CustomEditor and FormEditor to test both</li>
<li>Click "Set Value" to test setValue functionality</li>
<li>Click "Set Empty" to test empty content handling</li>
<li>Click "Set HTML Content" to test rich HTML content</li>
<li>Type in the editor to test onChange functionality</li>
<li>Submit the form to see all data</li>
</ul>
</Card>
</div>
);
}

Binary file not shown.

View File

@ -0,0 +1,102 @@
import React, { useRef, useEffect, useState, useCallback } from "react";
import { Editor } from "@tinymce/tinymce-react";
function FormEditor({ onChange, initialData }) {
const editorRef = useRef(null);
const [isEditorReady, setIsEditorReady] = useState(false);
const [editorContent, setEditorContent] = useState(initialData || "");
// Handle editor initialization
const handleInit = useCallback((evt, editor) => {
editorRef.current = editor;
setIsEditorReady(true);
// Set initial content when editor is ready
if (editorContent) {
editor.setContent(editorContent);
}
// Handle content changes
editor.on('change', () => {
const content = editor.getContent();
setEditorContent(content);
if (onChange) {
onChange(content);
}
});
}, [editorContent, onChange]);
// Watch for initialData changes (from setValue)
useEffect(() => {
if (initialData !== editorContent) {
setEditorContent(initialData || "");
// Update editor content if ready
if (editorRef.current && isEditorReady) {
editorRef.current.setContent(initialData || "");
}
}
}, [initialData, editorContent, isEditorReady]);
// Handle initial data when editor becomes ready
useEffect(() => {
if (isEditorReady && editorContent && editorRef.current) {
editorRef.current.setContent(editorContent);
}
}, [isEditorReady, editorContent]);
return (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
paste_as_text: false,
paste_enable_default_filters: true,
paste_word_valid_elements: 'b,strong,i,em,h1,h2,h3,h4,h5,h6',
paste_retain_style_properties: 'color background-color font-size font-weight',
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default FormEditor;

View File

@ -0,0 +1,81 @@
// components/minimal-editor.js
import React, { useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
function MinimalEditor(props) {
const editorRef = useRef(null);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Set initial content if provided
if (props.initialData) {
editor.setContent(props.initialData);
}
// Simple onChange handler - no debouncing, no complex logic
editor.on('change', () => {
if (props.onChange) {
props.onChange(editor.getContent());
}
});
};
return (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Minimal settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Basic content handling
paste_as_text: false,
paste_enable_default_filters: true,
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default MinimalEditor;

View File

@ -0,0 +1,105 @@
"use client";
import React, { useEffect, useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
interface OptimizedEditorProps {
initialData?: string;
onChange?: (data: string) => void;
height?: number;
placeholder?: string;
disabled?: boolean;
readOnly?: any;
}
const OptimizedEditor: React.FC<OptimizedEditorProps> = ({
initialData = "",
onChange,
height = 400,
placeholder = "Start typing...",
disabled = false,
readOnly = false,
}) => {
const editorRef = useRef<any>(null);
const handleEditorChange = (content: string) => {
if (onChange) {
onChange(content);
}
};
const handleInit = (evt: any, editor: any) => {
editorRef.current = editor;
};
return (
<Editor
onInit={handleInit}
initialValue={initialData}
onEditorChange={handleEditorChange}
disabled={disabled}
init={{
height,
menubar: false,
plugins: [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"code",
"help",
"wordcount",
],
toolbar:
"undo redo | blocks | " +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | table | code | help",
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: ${height - 32}px;
}
`,
placeholder,
readonly: readOnly,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Performance optimizations
cache_suffix: "?v=1.0",
browser_spellcheck: false,
gecko_spellcheck: false,
// Auto-save feature
auto_save: true,
auto_save_interval: "30s",
// Better mobile support
mobile: {
theme: "silver",
plugins: ["lists", "autolink", "link", "image", "table"],
toolbar: "bold italic | bullist numlist | link image",
},
}}
/>
);
};
export default OptimizedEditor;

View File

@ -0,0 +1,136 @@
// components/readonly-editor.js
import React, { useRef, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
function ReadOnlyEditor(props) {
const editorRef = useRef(null);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Set initial content if provided
if (props.initialData) {
editor.setContent(props.initialData);
}
// Disable all editing capabilities
editor.on('keydown keyup keypress input', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
editor.on('paste', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
editor.on('drop', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// Disable mouse events that might allow editing
editor.on('mousedown mousemove mouseup click dblclick', (e) => {
if (e.target.closest('.mce-content-body')) {
e.preventDefault();
e.stopPropagation();
return false;
}
});
};
// Update content when props change
useEffect(() => {
if (editorRef.current && props.initialData) {
editorRef.current.setContent(props.initialData);
}
}, [props.initialData]);
return (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false, // No toolbar for read-only mode
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body * {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Minimal settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Performance optimizations for read-only
cache_suffix: '?v=1.0',
browser_spellcheck: false,
gecko_spellcheck: false,
// Disable editing features
paste_as_text: true,
paste_enable_default_filters: false,
paste_word_valid_elements: false,
paste_retain_style_properties: false,
// Additional read-only settings
contextmenu: false,
selection: false,
// Disable all editing
object_resizing: false,
element_format: 'html',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: false
}
}}
/>
);
}
export default ReadOnlyEditor;

View File

@ -0,0 +1,95 @@
// components/simple-editor.js
import React, { useRef, useState, useCallback } from "react";
import { Editor } from "@tinymce/tinymce-react";
function SimpleEditor(props) {
const editorRef = useRef(null);
const [editorInstance, setEditorInstance] = useState(null);
const handleInit = useCallback((evt, editor) => {
editorRef.current = editor;
setEditorInstance(editor);
// Set initial content
if (props.initialData) {
editor.setContent(props.initialData);
}
// Disable automatic content updates
editor.settings.auto_focus = false;
editor.settings.forced_root_block = 'p';
// Store the onChange callback
editor.onChangeCallback = props.onChange;
// Handle content changes without triggering re-renders
editor.on('change keyup input', (e) => {
if (editor.onChangeCallback) {
const content = editor.getContent();
editor.onChangeCallback(content);
}
});
}, [props.initialData, props.onChange]);
return (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Better content handling
paste_as_text: false,
paste_enable_default_filters: true,
paste_word_valid_elements: 'b,strong,i,em,h1,h2,h3,h4,h5,h6',
paste_retain_style_properties: 'color background-color font-size font-weight',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default SimpleEditor;

View File

@ -0,0 +1,109 @@
// components/simple-readonly-editor.js
import React, { useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
function SimpleReadOnlyEditor(props) {
const editorRef = useRef(null);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Disable all editing capabilities
editor.on('keydown keyup keypress input', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
editor.on('paste', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
editor.on('drop', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// Disable mouse events that might allow editing
editor.on('mousedown mousemove mouseup click dblclick', (e) => {
if (e.target.closest('.mce-content-body')) {
e.preventDefault();
e.stopPropagation();
return false;
}
});
};
return (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body * {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
browser_spellcheck: false,
gecko_spellcheck: false,
paste_as_text: true,
paste_enable_default_filters: false,
contextmenu: false,
selection: false,
object_resizing: false,
element_format: 'html'
}}
/>
);
}
export default SimpleReadOnlyEditor;

View File

@ -0,0 +1,93 @@
import React, { useRef, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
function StableEditor(props) {
const editorRef = useRef(null);
const onChangeRef = useRef(props.onChange);
// Update onChange ref when props change
useEffect(() => {
onChangeRef.current = props.onChange;
}, [props.onChange]);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Set initial content if provided
if (props.initialData) {
editor.setContent(props.initialData);
}
// Use a simple change handler that doesn't trigger re-renders
editor.on('change', () => {
if (onChangeRef.current) {
onChangeRef.current(editor.getContent());
}
});
};
return (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings for stability
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable all problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Content handling
paste_as_text: false,
paste_enable_default_filters: true,
paste_word_valid_elements: 'b,strong,i,em,h1,h2,h3,h4,h5,h6',
paste_retain_style_properties: 'color background-color font-size font-weight',
// Prevent automatic updates
element_format: 'html',
valid_children: '+body[style]',
extended_valid_elements: 'span[*]',
custom_elements: '~span',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default StableEditor;

View File

@ -0,0 +1,93 @@
import React, { useRef, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
function StaticEditor(props) {
const editorRef = useRef(null);
const onChangeRef = useRef(props.onChange);
// Update onChange ref when props change
useEffect(() => {
onChangeRef.current = props.onChange;
}, [props.onChange]);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Set initial content if provided
if (props.initialData) {
editor.setContent(props.initialData);
}
// Use a simple change handler that doesn't trigger re-renders
editor.on('change', () => {
if (onChangeRef.current) {
onChangeRef.current(editor.getContent());
}
});
};
return (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable all problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Content handling
paste_as_text: false,
paste_enable_default_filters: true,
paste_word_valid_elements: 'b,strong,i,em,h1,h2,h3,h4,h5,h6',
paste_retain_style_properties: 'color background-color font-size font-weight',
// Prevent automatic updates
element_format: 'html',
valid_children: '+body[style]',
extended_valid_elements: 'span[*]',
custom_elements: '~span',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default StaticEditor;

View File

@ -0,0 +1,113 @@
// components/strict-readonly-editor.js
import React, { useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
function StrictReadOnlyEditor(props) {
const editorRef = useRef(null);
const handleInit = (evt, editor) => {
editorRef.current = editor;
// Disable all possible editing events
const disableEvents = ['keydown', 'keyup', 'keypress', 'input', 'paste', 'drop', 'cut', 'copy'];
disableEvents.forEach(eventType => {
editor.on(eventType, (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
});
});
// Disable mouse events that might allow editing
editor.on('mousedown mousemove mouseup click dblclick', (e) => {
if (e.target.closest('.mce-content-body')) {
e.preventDefault();
e.stopPropagation();
return false;
}
});
// Disable focus events
editor.on('focus blur', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
};
return (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
.mce-content-body * {
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
browser_spellcheck: false,
gecko_spellcheck: false,
paste_as_text: true,
paste_enable_default_filters: false,
contextmenu: false,
selection: false,
object_resizing: false,
element_format: 'html',
// Additional strict settings
valid_children: false,
extended_valid_elements: false,
custom_elements: false
}}
/>
);
}
export default StrictReadOnlyEditor;

View File

@ -0,0 +1,303 @@
"use client";
import React, { useRef, useState, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
interface TinyMCEEditorProps {
initialData?: string;
onChange?: (data: string) => void;
onReady?: (editor: any) => void;
height?: number;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
features?: "basic" | "standard" | "full";
toolbar?: string;
language?: string;
uploadUrl?: string;
uploadHeaders?: Record<string, string>;
className?: string;
autoSave?: boolean;
autoSaveInterval?: number;
}
const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
initialData = "",
onChange,
onReady,
height = 400,
placeholder = "Start typing...",
disabled = false,
readOnly = false,
features = "standard",
toolbar,
language = "en",
uploadUrl,
uploadHeaders,
className = "",
autoSave = true,
autoSaveInterval = 30000,
}) => {
const editorRef = useRef<any>(null);
const [isEditorLoaded, setIsEditorLoaded] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [wordCount, setWordCount] = useState(0);
// Feature-based configurations
const getFeatureConfig = (featureLevel: string) => {
const configs = {
basic: {
plugins: ["lists", "link", "autolink", "wordcount"],
toolbar: "bold italic | bullist numlist | link",
menubar: false,
},
standard: {
plugins: [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"help",
"wordcount",
],
toolbar:
"undo redo | blocks | " +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | table | code | help",
menubar: false,
},
full: {
plugins: [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"help",
"wordcount",
"emoticons",
"paste",
"textcolor",
"colorpicker",
"hr",
"pagebreak",
"nonbreaking",
"toc",
"imagetools",
"textpattern",
"codesample",
],
toolbar:
"undo redo | formatselect | bold italic backcolor | " +
"alignleft aligncenter alignright alignjustify | " +
"bullist numlist outdent indent | removeformat | help",
menubar: "file edit view insert format tools table help",
},
};
return configs[featureLevel as keyof typeof configs] || configs.standard;
};
const handleEditorChange = (content: string) => {
if (onChange) {
onChange(content);
}
};
const handleEditorInit = (evt: any, editor: any) => {
editorRef.current = editor;
setIsEditorLoaded(true);
if (onReady) {
onReady(editor);
}
// Set up word count tracking
editor.on("keyup", () => {
const count = editor.plugins.wordcount.body.getCharacterCount();
setWordCount(count);
});
// Set up auto-save
if (autoSave && !readOnly) {
setInterval(() => {
const content = editor.getContent();
localStorage.setItem("tinymce-autosave", content);
setLastSaved(new Date());
}, autoSaveInterval);
}
// Fix cursor jumping issues
editor.on("keyup", (e: any) => {
// Prevent cursor jumping on content changes
e.stopPropagation();
});
editor.on("input", (e: any) => {
// Prevent unnecessary re-renders
e.stopPropagation();
});
// Handle paste events properly
editor.on("paste", (e: any) => {
// Allow default paste behavior
return true;
});
};
const handleImageUpload = (blobInfo: any, progress: any) => {
return new Promise((resolve, reject) => {
if (!uploadUrl) {
reject("No upload URL configured");
return;
}
const formData = new FormData();
formData.append("file", blobInfo.blob(), blobInfo.filename());
fetch(uploadUrl, {
method: "POST",
headers: uploadHeaders || {},
body: formData,
})
.then((response) => response.json())
.then((result) => {
resolve(result.url);
})
.catch((error) => {
reject(error);
});
});
};
const featureConfig = getFeatureConfig(features);
const editorConfig = {
height,
language,
placeholder,
branding: false,
elementpath: false,
resize: false,
statusbar: !readOnly,
// Performance optimizations
cache_suffix: "?v=1.0",
browser_spellcheck: false,
gecko_spellcheck: false,
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
margin: 0;
padding: 16px;
}
.mce-content-body {
min-height: ${height - 32}px;
}
.mce-content-body:focus {
outline: none;
}
`,
images_upload_handler: uploadUrl ? handleImageUpload : undefined,
automatic_uploads: !!uploadUrl,
file_picker_types: "image",
mobile: {
theme: "silver",
plugins: ["lists", "autolink", "link", "image", "table"],
toolbar: "bold italic | bullist numlist | link image",
},
paste_as_text: false,
paste_enable_default_filters: true,
paste_word_valid_elements: "b,strong,i,em,h1,h2,h3,h4,h5,h6",
paste_retain_style_properties:
"color background-color font-size font-weight",
table_default_styles: { width: "100%" },
table_default_attributes: { border: "1" },
codesample_languages: [
{ text: "HTML/XML", value: "markup" },
{ text: "JavaScript", value: "javascript" },
{ text: "CSS", value: "css" },
{ text: "PHP", value: "php" },
{ text: "Python", value: "python" },
{ text: "Java", value: "java" },
{ text: "C", value: "c" },
{ text: "C++", value: "cpp" },
],
...featureConfig,
...(toolbar && { toolbar }),
setup: (editor: any) => {
// ⬅️ Set readOnly di sini
editor.on("init", () => {
if (readOnly) {
editor.mode.set("readonly");
}
});
},
};
return (
<div className={`tinymce-editor-container ${className}`}>
<Editor
onInit={handleEditorInit}
initialValue={initialData}
onEditorChange={handleEditorChange}
disabled={disabled}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={editorConfig}
/>
{/* Status bar */}
{isEditorLoaded && (
<div className="text-xs text-gray-500 mt-2 flex items-center justify-between">
<div className="flex items-center space-x-4">
<span>
{autoSave && !readOnly ? "Auto-save enabled" : "Read-only mode"}
</span>
{lastSaved && autoSave && !readOnly && (
<span> Last saved: {lastSaved.toLocaleTimeString()}</span>
)}
<span> {wordCount} characters</span>
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{features} mode
</span>
</div>
)}
{/* Performance indicator */}
<div className="text-xs text-gray-400 mt-1">
Bundle size:{" "}
{features === "basic"
? "~150KB"
: features === "standard"
? "~200KB"
: "~300KB"}
</div>
</div>
);
};
export default TinyMCEEditor;

View File

@ -0,0 +1,263 @@
import React from "react";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import Editor from "@/vendor/ckeditor5/build/ckeditor";
function ViewEditor(props) {
const maxHeight = props.maxHeight || 600; // Default max height 600px
return (
<div className="ckeditor-view-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
disabled={true}
config={{
isReadOnly: true,
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #111;
background: #fff;
margin: 0;
padding: 0;
}
p {
margin: 0.5em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
}
ul, ol {
margin: 0.5em 0;
padding-left: 2em;
}
blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #d1d5db;
background-color: #f9fafb;
}
`,
height: props.height || 400,
removePlugins: ["Title"],
}}
/>
<style jsx>{`
.ckeditor-view-wrapper {
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.ckeditor-view-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px;
max-height: ${maxHeight}px;
}
.ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
min-height: ${(props.height || 400) - 50}px;
max-height: ${maxHeight - 50}px;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
background-color: #fdfdfd;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #111;
}
/* 🌙 Dark mode support */
:global(.dark) .ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
background-color: #111 !important;
color: #f9fafb !important;
border-color: #374151;
}
:global(.dark) .ckeditor-view-wrapper h1,
:global(.dark) .ckeditor-view-wrapper h2,
:global(.dark) .ckeditor-view-wrapper h3,
:global(.dark) .ckeditor-view-wrapper h4,
:global(.dark) .ckeditor-view-wrapper h5,
:global(.dark) .ckeditor-view-wrapper h6 {
color: #f9fafb !important;
}
:global(.dark) .ckeditor-view-wrapper blockquote {
background-color: #1f2937 !important;
border-left: 4px solid #374151 !important;
color: #f3f4f6 !important;
}
/* Custom scrollbar styling */
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar) {
width: 8px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #f1f5f9;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #cbd5e1;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #94a3b8;
}
/* 🌙 Dark mode scrollbar */
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #1f2937;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #4b5563;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #6b7280;
}
/* Read-only specific styling */
.ckeditor-view-wrapper :global(.ck.ck-editor__editable.ck-read-only) {
cursor: default;
}
/* Hide toolbar */
.ckeditor-view-wrapper :global(.ck.ck-toolbar) {
display: none !important;
}
`}</style>
</div>
);
}
export default ViewEditor;
// import React from "react";
// import { CKEditor } from "@ckeditor/ckeditor5-react";
// import Editor from "ckeditor5-custom-build";
// function ViewEditor(props) {
// const maxHeight = props.maxHeight || 600;
// return (
// <div className="ckeditor-view-wrapper">
// <CKEditor
// editor={Editor}
// data={props.initialData}
// disabled={true}
// config={{
// // toolbar: [],
// isReadOnly: true,
// // Add content styling configuration for read-only mode
// content_style: `
// body {
// font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
// font-size: 14px;
// line-height: 1.6;
// color: #333;
// margin: 0;
// padding: 0;
// }
// p {
// margin: 0.5em 0;
// }
// h1, h2, h3, h4, h5, h6 {
// margin: 1em 0 0.5em 0;
// }
// ul, ol {
// margin: 0.5em 0;
// padding-left: 2em;
// }
// blockquote {
// margin: 1em 0;
// padding: 0.5em 1em;
// border-left: 4px solid #d1d5db;
// background-color: #f9fafb;
// }
// `,
// // Editor appearance settings
// height: props.height || 400,
// removePlugins: ['Title'],
// }}
// />
// <style jsx>{`
// .ckeditor-view-wrapper {
// border-radius: 6px;
// overflow: hidden;
// box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__main) {
// min-height: ${props.height || 400}px;
// max-height: ${maxHeight}px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
// min-height: ${(props.height || 400) - 50}px;
// max-height: ${maxHeight - 50}px;
// overflow-y: auto !important;
// scrollbar-width: thin;
// scrollbar-color: #cbd5e1 #f1f5f9;
// background-color:rgb(253, 253, 253);
// border: 1px solid #d1d5db;
// border-radius: 6px;
// }
// /* Custom scrollbar styling for webkit browsers */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar) {
// width: 8px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
// background: #f1f5f9;
// border-radius: 4px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
// background: #cbd5e1;
// border-radius: 4px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
// background: #94a3b8;
// }
// /* Ensure content doesn't overflow */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable .ck-content) {
// overflow: hidden;
// }
// /* Read-only specific styling */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable.ck-read-only) {
// background-color: #f8fafc;
// color: #4b5563;
// cursor: default;
// }
// /* Hide toolbar for view-only mode */
// .ckeditor-view-wrapper :global(.ck.ck-toolbar) {
// display: none !important;
// }
// `}</style>
// </div>
// );
// }
// export default ViewEditor;

View File

@ -0,0 +1,918 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import dynamic from "next/dynamic";
import { useDropzone } from "react-dropzone";
import { CloudUploadIcon, TimesIcon } from "@/components/icons";
import Image from "next/image";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { convertDateFormatNoTime, htmlToString } from "@/utils/global";
import { close, error, loading, successToast } from "@/config/swal";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Cookies from "js-cookie";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createArticle,
createArticleSchedule,
getArticleByCategory,
uploadArticleFile,
uploadArticleThumbnail,
} from "@/service/article";
import {
saveManualContext,
updateManualArticle,
} from "@/service/generate-article";
import { getUserLevels } from "@/service/user-levels-service";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { getCategoryById } from "@/service/master-categories";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import GenerateSingleArticleForm from "./generate-ai-single-form";
import GenerateContentRewriteForm from "./generate-ai-content-rewrite-form";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import DatePicker from "react-datepicker";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
interface FileWithPreview extends File {
preview: string;
}
interface CategoryType {
id: number;
label: string;
value: number;
}
const categorySchema = z.object({
id: z.number(),
label: z.string(),
value: z.number(),
});
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
const createArticleSchema = z.object({
title: z.string().min(2, {
message: "Judul harus diisi",
}),
customCreatorName: z.string().min(2, {
message: "Judul harus diisi",
}),
slug: z.string().min(2, {
message: "Slug harus diisi",
}),
description: z.string().min(2, {
message: "Deskripsi harus diisi",
}),
category: z.array(categorySchema).nonempty({
message: "Kategori harus memiliki setidaknya satu item",
}),
tags: z.array(z.string()).nonempty({
message: "Minimal 1 tag",
}),
source: z.enum(["internal", "external"]).optional(),
});
export default function CreateArticleForm() {
const userLevel = Cookies.get("ulne");
const animatedComponents = makeAnimated();
const MySwal = withReactContent(Swal);
const router = useRouter();
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [useAi, setUseAI] = useState(false);
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [tag, setTag] = useState("");
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(
null
);
const [thumbnailValidation, setThumbnailValidation] = useState("");
const [filesValidation, setFileValidation] = useState("");
const [diseData, setDiseData] = useState<DiseData>();
const [selectedWritingType, setSelectedWritingType] = useState("single");
const [status, setStatus] = useState<"publish" | "draft" | "scheduled">(
"publish"
);
const [isScheduled, setIsScheduled] = useState(false);
const [startDateValue, setStartDateValue] = useState<Date | undefined>();
const [startTimeValue, setStartTimeValue] = useState<string>("");
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles((prevFiles) => [
...prevFiles,
...acceptedFiles.map((file) => Object.assign(file)),
]);
},
multiple: true,
accept: {
"image/*": [],
},
});
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", category: [], tags: [] },
};
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
getValues,
watch,
setError,
clearErrors,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
fetchCategory();
}, []);
const fetchCategory = async () => {
const res = await getArticleByCategory();
if (res?.data?.data) {
setupCategory(res?.data?.data);
}
};
const setupCategory = (data: any) => {
const temp = [];
for (const element of data) {
temp.push({
id: element.id,
label: element.title,
value: element.id,
});
}
setListCategory(temp);
};
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
if ((thumbnailImg.length < 1 && !selectedMainImage) || files.length < 1) {
if (files.length < 1) {
setFileValidation("Required");
} else {
setFileValidation("");
}
if (thumbnailImg.length < 1 && !selectedMainImage) {
setThumbnailValidation("Required");
} else {
setThumbnailValidation("");
}
} else {
setThumbnailValidation("");
setFileValidation("");
MySwal.fire({
title: "Simpan Data",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(values);
}
});
}
};
useEffect(() => {
if (useAi === false) {
setValue("description", "");
}
}, [useAi]);
function removeImgTags(htmlString: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(String(htmlString), "text/html");
const images = doc.querySelectorAll("img");
images.forEach((img) => img.remove());
return doc.body.innerHTML;
}
const saveArticleToDise = async (
values: z.infer<typeof createArticleSchema>
) => {
if (useAi) {
const request = {
id: diseData?.id,
title: values.title,
customCreatorName: values.customCreatorName,
source: values.source,
articleBody: removeImgTags(values.description),
metaDescription: diseData?.metaDescription,
metaTitle: diseData?.metaTitle,
mainKeyword: diseData?.mainKeyword,
additionalKeywords: diseData?.additionalKeywords,
createdBy: "345",
style: "Informational",
projectId: 2,
clientId: "humasClientIdtest",
lang: "id",
};
const res = await updateManualArticle(request);
if (res.error) {
error(res.message);
return false;
}
return diseData?.id;
} else {
const request = {
title: values.title,
articleBody: removeImgTags(values.description),
metaDescription: values.title,
metaTitle: values.title,
mainKeyword: values.title,
additionalKeywords: values.title,
createdBy: "345",
style: "Informational",
projectId: 2,
clientId: "humasClientIdtest",
lang: "id",
};
const res = await saveManualContext(request);
if (res.error) {
res.message;
return 0;
}
return res?.data?.data?.id;
}
};
const getUserLevelApprovalStatus = async () => {
const res = await getUserLevels(String(userLevel));
return res?.data?.data?.isApprovalActive;
};
const save = async (values: z.infer<typeof createArticleSchema>) => {
loading();
const userLevelStatus = await getUserLevelApprovalStatus();
const formData = {
title: values.title,
typeId: 1,
slug: values.slug,
customCreatorName: values.customCreatorName,
source: values.source,
categoryIds: values.category.map((a) => a.id).join(","),
tags: values.tags.join(","),
description: htmlToString(removeImgTags(values.description)),
htmlDescription: removeImgTags(values.description),
aiArticleId: await saveArticleToDise(values),
// isDraft: userLevelStatus ? true : status === "draft",
// isPublish: userLevelStatus ? false : status === "publish",
isDraft: status === "draft",
isPublish: status === "publish",
};
const response = await createArticle(formData);
if (response?.error) {
error(response.message);
return false;
}
const articleId = response?.data?.data?.id;
if (files?.length > 0) {
const formFiles = new FormData();
for (const element of files) {
formFiles.append("file", element);
const resFile = await uploadArticleFile(articleId, formFiles);
}
}
if (thumbnailImg?.length > 0 || files?.length > 0) {
if (thumbnailImg?.length > 0) {
const formFiles = new FormData();
formFiles.append("files", thumbnailImg[0]);
const resFile = await uploadArticleThumbnail(articleId, formFiles);
} else {
const formFiles = new FormData();
if (selectedMainImage) {
formFiles.append("files", files[selectedMainImage - 1]);
const resFile = await uploadArticleThumbnail(articleId, formFiles);
}
}
}
if (status === "scheduled" && startDateValue) {
// ambil waktu, default 00:00 jika belum diisi
const [hours, minutes] = startTimeValue
? startTimeValue.split(":").map(Number)
: [0, 0];
// gabungkan tanggal + waktu
const combinedDate = new Date(startDateValue);
combinedDate.setHours(hours, minutes, 0, 0);
// format: 2025-10-08 14:30:00
const formattedDateTime = `${combinedDate.getFullYear()}-${String(
combinedDate.getMonth() + 1
).padStart(2, "0")}-${String(combinedDate.getDate()).padStart(
2,
"0"
)} ${String(combinedDate.getHours()).padStart(2, "0")}:${String(
combinedDate.getMinutes()
).padStart(2, "0")}:00`;
const request = {
id: articleId,
date: formattedDateTime,
};
console.log("📤 Sending schedule request:", request);
const res = await createArticleSchedule(request);
console.log("✅ Schedule response:", res);
}
close();
successSubmit("/admin/article", articleId, values.slug);
};
function successSubmit(redirect: string, id: number, slug: string) {
const url =
`${window.location.protocol}//${window.location.host}` +
"/news/detail/" +
`${id}-${slug}`;
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
successToast("Article Url", url);
} else {
router.push(redirect);
successToast("Article Url", url);
}
});
}
const watchTitle = watch("title");
const generateSlug = (title: string) => {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
};
useEffect(() => {
setValue("slug", generateSlug(watchTitle));
}, [watchTitle]);
const renderFilePreview = (file: FileWithPreview) => {
if (file.type.startsWith("image")) {
return (
<Image
width={48}
height={48}
alt={file.name}
src={URL.createObjectURL(file)}
className=" rounded border p-0.5"
/>
);
} else {
return "Not Found";
}
};
const handleRemoveFile = (file: FileWithPreview) => {
const uploadedFiles = files;
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
setFiles([...filtered]);
};
const fileList = files.map((file, index) => (
<div
key={file.name}
className=" flex justify-between border px-3.5 py-3 my-6 rounded-md"
>
<div className="flex gap-3 items-center">
<div className="file-preview">{renderFilePreview(file)}</div>
<div>
<div className=" text-sm text-card-foreground">{file.name}</div>
<div className=" text-xs font-light text-muted-foreground">
{Math.round(file.size / 100) / 10 > 1000 ? (
<>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
) : (
<>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
)}
{" kb"}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={String(index)}
value={String(index)}
checked={selectedMainImage === index + 1}
onCheckedChange={() => setSelectedMainImage(index + 1)}
/>
<label htmlFor={String(index)} className="text-black text-xs">
Jadikan Thumbnail
</label>
</div>
</div>
</div>
<Button
className="rounded-full"
variant="ghost"
onClick={() => handleRemoveFile(file)}
>
<TimesIcon />
</Button>
</div>
));
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = event.target.files;
if (selectedFiles) {
setThumbnailImg(Array.from(selectedFiles));
}
};
const selectedCategory = watch("category");
useEffect(() => {
getDetailCategory();
}, [selectedCategory]);
const getDetailCategory = async () => {
let temp = getValues("tags");
for (const element of selectedCategory) {
const res = await getCategoryById(element?.id);
const tagList = res?.data?.data?.tags;
if (tagList) {
temp = [...temp, ...res?.data?.data?.tags];
}
}
const uniqueArray = temp.filter(
(item, index) => temp.indexOf(item) === index
);
setValue("tags", uniqueArray as [string, ...string[]]);
};
return (
<form
className="flex flex-col lg:flex-row gap-8 text-black"
onSubmit={handleSubmit(onSubmit)}
>
<div className="w-full lg:w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1">
<p className="text-sm">Judul</p>
<Controller
control={control}
name="title"
render={({ field }) => (
<Input
id="title"
type="text"
placeholder="Masukkan judul artikel"
className="w-full border rounded-lg dark:border-gray-400"
{...field}
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm mb-3">{errors.title?.message}</p>
)}
<p className="text-sm mt-3">Slug</p>
<Controller
control={control}
name="slug"
render={({ field }) => (
<Input
type="text"
id="title"
placeholder=""
value={field.value ?? ""}
onChange={field.onChange}
className="w-full border rounded-lg dark:border-gray-400"
/>
)}
/>
{errors?.slug && (
<p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
)}
<div className="flex items-center gap-2 mt-3">
<Switch checked={useAi} onCheckedChange={setUseAI} />
<p className="text-sm text-black">Bantuan AI</p>
</div>
{useAi && (
<div className="flex flex-col gap-2">
<Select
value={selectedWritingType ?? ""}
onValueChange={(value) => {
if (value !== "") setSelectedWritingType(value);
}}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Article</SelectItem>
{/* <SelectItem value="rewrite">Content Rewrite</SelectItem> */}
</SelectContent>
</Select>
{selectedWritingType === "single" ? (
<GenerateSingleArticleForm
content={(data) => {
setDiseData(data);
// setValue("title", data?.title ?? "", {
// shouldValidate: true,
// shouldDirty: true,
// });
// setValue("slug", generateSlug(data?.title ?? ""), {
// shouldValidate: true,
// shouldDirty: true,
// });
setValue(
"description",
data?.articleBody ? data?.articleBody : ""
);
}}
/>
) : (
<GenerateContentRewriteForm
content={(data) => {
setDiseData(data);
setValue(
"description",
data?.articleBody ? data?.articleBody : ""
);
}}
/>
)}
</div>
)}
<p className="text-sm mt-3">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<CustomEditor onChange={onChange} initialData={value} />
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm mb-3">
{errors.description?.message}
</p>
)}
<p className="text-sm mt-3">File Media</p>
<Fragment>
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon size={50} className="text-gray-300" />
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
Tarik file disini atau klik untuk upload.
</h4>
<div className=" text-xs text-muted-foreground">
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
maksimal 100mb.)
</div>
</div>
</div>
{files.length ? (
<Fragment>
<div>{fileList}</div>
<div className="flex justify-between gap-2">
<Button onClick={() => setFiles([])} size="sm">
Hapus Semua
</Button>
</div>
</Fragment>
) : null}
</Fragment>
{filesValidation !== "" && files.length < 1 && (
<p className="text-red-400 text-sm mb-3">Upload File Media</p>
)}
</div>
<div className="w-full lg:w-[35%] flex flex-col gap-8">
<div className="h-fit bg-white rounded-lg p-8 flex flex-col gap-1">
<p className="text-sm">Thubmnail</p>
{selectedMainImage && files.length >= selectedMainImage ? (
<div className="flex flex-row">
<img
src={URL.createObjectURL(files[selectedMainImage - 1])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
className="border-none rounded-full"
variant="outline"
size="sm"
onClick={() => setSelectedMainImage(null)}
>
<TimesIcon />
</Button>
</div>
) : thumbnailImg.length > 0 ? (
<div className="flex flex-row">
<img
src={URL.createObjectURL(thumbnailImg[0])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
className="border-none rounded-full"
variant="outline"
size="sm"
onClick={() => setThumbnailImg([])}
>
<TimesIcon />
</Button>
</div>
) : (
<>
{/* <label htmlFor="file-upload">
<button>Upload Thumbnail</button>
</label>{" "} */}
<input
id="file-upload"
type="file"
multiple
className="w-fit h-fit"
accept="image/*"
onChange={handleFileChange}
/>
{thumbnailValidation !== "" && (
<p className="text-red-400 text-sm mb-3">
Upload thumbnail atau pilih dari File Media
</p>
)}
</>
)}
<p className="text-sm">Kreator</p>
<Controller
control={control}
name="customCreatorName"
render={({ field }) => (
<Input
id="customCreatorName"
type="text"
placeholder="Masukkan judul artikel"
className="w-full border rounded-lg dark:border-gray-400"
{...field}
/>
)}
/>
<div className="mt-2">
<p className="text-sm">Tipe Kreator</p>
<Controller
control={control}
name="source"
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full border rounded-lg text-sm dark:border-gray-400">
<SelectValue placeholder="Pilih tipe kreator" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">Internal</SelectItem>
<SelectItem value="external">External</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<p className="text-sm mt-3">Kategori</p>
<Controller
control={control}
name="category"
render={({ field: { onChange, value } }) => (
<ReactSelect
className="basic-single text-black z-50"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
value={value}
onChange={(selected) => {
onChange(selected);
}}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={true}
placeholder="Kategori..."
name="sub-module"
options={listCategory}
/>
)}
/>
{errors?.category && (
<p className="text-red-400 text-sm mb-3">
{errors.category?.message}
</p>
)}
<p className="text-sm">Tags</p>
<Controller
control={control}
name="tags"
render={({ field: { value } }) => (
<div className="w-full">
{/* Menampilkan tags */}
<div className="flex flex-wrap gap-1 mb-2">
{value.map((item: string, index: number) => (
<Badge
key={index}
className="flex items-center gap-1 px-2 py-1 text-sm"
variant="secondary"
>
{item}
<button
type="button"
onClick={() => {
const filteredTags = value.filter(
(tag: string) => tag !== item
);
if (filteredTags.length === 0) {
setError("tags", {
type: "manual",
message: "Tags tidak boleh kosong",
});
} else {
clearErrors("tags");
setValue(
"tags",
filteredTags as [string, ...string[]]
);
}
}}
className="text-red-500 text-xs ml-1"
>
×
</button>
</Badge>
))}
</div>
{/* Textarea input */}
<Textarea
id="tags"
placeholder="Tekan Enter untuk menambahkan tag"
value={tag ?? ""}
onChange={(e) => setTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (tag.trim() !== "") {
setValue("tags", [...value, tag.trim()]);
setTag("");
clearErrors("tags");
}
}
}}
className="border rounded-lg"
aria-label="Tags Input"
/>
</div>
)}
/>
{errors?.tags && (
<p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
)}
<div className="flex flex-col gap-2 mt-3">
<div className="flex items-center space-x-2">
<Switch
id="schedule-switch"
checked={isScheduled}
onCheckedChange={setIsScheduled}
/>
<label htmlFor="schedule-switch" className="text-black text-sm">
Publish dengan Jadwal
</label>
</div>
{isScheduled && (
<div className="flex flex-col lg:flex-row gap-3 mt-2">
{/* Pilih tanggal */}
<div className="w-full lg:w-[140px] flex flex-col gap-2">
<p className="text-sm">Tanggal</p>
<Popover>
<PopoverTrigger>
<Button
type="button"
className="w-full !h-[37px] lg:h-[37px] border-1 rounded-lg text-black"
variant="outline"
>
{startDateValue
? startDateValue.toISOString().split("T")[0]
: "-"}
</Button>
</PopoverTrigger>
<PopoverContent className="bg-transparent p-0">
<DatePicker
selected={startDateValue}
onChange={(date) =>
setStartDateValue(date ?? undefined)
}
dateFormat="yyyy-MM-dd"
className="w-full border rounded-lg px-2 py-1 text-black cursor-pointer h-[150px]"
placeholderText="Pilih tanggal"
/>
</PopoverContent>
</Popover>
</div>
{/* Pilih waktu */}
<div className="w-full lg:w-[140px] flex flex-col gap-2">
<p className="text-sm">Waktu</p>
<input
type="time"
value={startTimeValue}
onChange={(e) => setStartTimeValue(e.target.value)}
className="w-full border rounded-lg px-2 py-[6px] text-black"
/>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-row justify-end gap-3">
<Button
color="primary"
type="submit"
disabled={isScheduled && startDateValue == null}
onClick={() =>
isScheduled ? setStatus("scheduled") : setStatus("publish")
}
>
Publish
</Button>
<Button
color="success"
type="submit"
onClick={() => setStatus("draft")}
>
<p className="text-white">Draft</p>
</Button>
<Link href="/admin/article">
<Button variant="outline" type="button">
Kembali
</Button>
</Link>
</div>
</div>
</form>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,342 @@
"use client";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { delay } from "@/utils/global";
import dynamic from "next/dynamic";
import GetSeoScore from "./get-seo-score-form";
import {
getDetailArticle,
getGenerateRewriter,
} from "@/service/generate-article";
import { Button } from "@/components/ui/button";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
const writingStyle = [
{
id: 1,
name: "Friendly",
},
{
id: 1,
name: "Professional",
},
{
id: 3,
name: "Informational",
},
{
id: 4,
name: "Neutral",
},
{
id: 5,
name: "Witty",
},
];
const articleSize = [
{
id: 1,
name: "News (300 - 900 words)",
value: "News",
},
{
id: 2,
name: "Info (900 - 2000 words)",
value: "Info",
},
{
id: 3,
name: "Detail (2000 - 5000 words)",
value: "Detail",
},
];
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
export default function GenerateContentRewriteForm(props: {
content: (data: DiseData) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] =
useState("Informational");
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
const [selectedLanguage, setSelectedLanguage] = useState("id");
const [mainKeyword, setMainKeyword] = useState("");
const [articleIds, setArticleIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number>();
const [isLoading, setIsLoading] = useState(true);
const onSubmit = async () => {
loading();
const request = {
advConfig: "",
context: mainKeyword,
style: selectedWritingSyle,
sentiment: "Informational",
urlContext: null,
contextType: "article",
lang: selectedLanguage,
createdBy: "123123",
clientId: "humasClientIdtest",
};
const res = await getGenerateRewriter(request);
close();
if (res?.error) {
error("Error");
}
setArticleIds([...articleIds, res?.data?.data?.id]);
};
useEffect(() => {
getArticleDetail();
}, [selectedId]);
const checkArticleStatus = async (data: string | null) => {
if (data === null) {
delay(7000).then(() => {
getArticleDetail();
});
}
};
const getArticleDetail = async () => {
if (selectedId) {
const res = await getDetailArticle(selectedId);
const data = res?.data?.data;
checkArticleStatus(data?.articleBody);
if (data?.articleBody !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content({
id: data?.id,
articleBody: "",
title: "",
metaTitle: "",
description: "",
metaDescription: "",
additionalKeywords: "",
mainKeyword: "",
});
}
}
};
return (
<fieldset>
<form className="flex flex-col w-full mt-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
{/* <Select
label="Writing Style"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedWritingSyle]}
onChange={(e) =>
e.target.value !== ""
? setSelectedWritingStyle(e.target.value)
: ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
{writingStyle.map((style) => (
<SelectItem key={style.name}>{style.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<div className="w-full space-y-1">
<label className="text-sm font-medium text-black dark:text-white">
Writing Style
</label>
<Select
value={selectedWritingSyle}
onValueChange={(value) => setSelectedWritingStyle(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{writingStyle.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* <Select
label="Article Size"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedArticleSize]}
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
{articleSize.map((size) => (
<SelectItem key={size.value}>{size.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<div className="w-full space-y-1">
<label className="text-sm font-medium text-black dark:text-white">
Article Size
</label>
<Select
value={selectedArticleSize}
onValueChange={(value) => setSelectedArticleSize(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{articleSize.map((size) => (
<SelectItem key={size.name} value={size.name}>
{size.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* <Select
label="Bahasa"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedLanguage]}
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
<SelectItem key="id">Indonesia</SelectItem>
<SelectItem key="en">English</SelectItem>
</SelectSection>
</Select> */}
<div className="w-full space-y-1">
<label className="text-sm font-medium text-black dark:text-white">
Language
</label>
<Select
value={selectedLanguage}
onValueChange={(value) => setSelectedLanguage(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col mt-3">
<div className="flex flex-row gap-2 items-center">
<p className="text-sm">Text</p>
</div>
<div className="w-[78vw] lg:w-full">
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
</div>
{mainKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)}
{/* {articleIds.length < 3 && (
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == ""}>
Generate
</Button>
)} */}
{articleIds.length < 3 && (
<Button
onClick={onSubmit}
type="button"
disabled={mainKeyword === ""}
className="my-5 w-full py-5 text-xs md:text-base"
>
Generate
</Button>
)}
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1 mt-2">
{/* {articleIds?.map((id, index) => (
<Button onPress={() => setSelectedId(id)} key={id} isLoading={isLoading && selectedId == id} color={selectedId == id && isLoading ? "warning" : selectedId == id ? "success" : "default"}>
<p className={selectedId == id ? "text-white" : "text-black"}>Article {index + 1}</p>
</Button>
))} */}
{articleIds?.map((id, index) => (
<Button
key={id}
onClick={() => setSelectedId(id)}
disabled={isLoading && selectedId === id}
variant={
selectedId === id
? isLoading
? "secondary"
: "default"
: "outline"
}
className={
selectedId === id ? "bg-green-600 text-white" : "text-black"
}
>
{isLoading && selectedId === id
? "Loading..."
: `Article ${index + 1}`}
</Button>
))}
</div>
)}
{!isLoading && (
<div>
<GetSeoScore id={String(selectedId)} />
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,323 @@
"use client";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { delay } from "@/utils/global";
import dynamic from "next/dynamic";
import {
getDetailArticle,
getGenerateRewriter,
} from "@/service/generate-article";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import GetSeoScore from "./get-seo-score-form";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
const writingStyle = [
{
id: 1,
name: "Friendly",
},
{
id: 1,
name: "Professional",
},
{
id: 3,
name: "Informational",
},
{
id: 4,
name: "Neutral",
},
{
id: 5,
name: "Witty",
},
];
const articleSize = [
{
id: 1,
name: "News (300 - 900 words)",
value: "News",
},
{
id: 2,
name: "Info (900 - 2000 words)",
value: "Info",
},
{
id: 3,
name: "Detail (2000 - 5000 words)",
value: "Detail",
},
];
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
export default function GenerateContentRewriteForm(props: {
content: (data: DiseData) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] =
useState("Informational");
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
const [selectedLanguage, setSelectedLanguage] = useState("id");
const [mainKeyword, setMainKeyword] = useState("");
const [articleIds, setArticleIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number>();
const [isLoading, setIsLoading] = useState(true);
const onSubmit = async () => {
loading();
const request = {
advConfig: "",
context: mainKeyword,
style: selectedWritingSyle,
sentiment: "Informational",
urlContext: null,
contextType: "article",
lang: selectedLanguage,
createdBy: "123123",
clientId: "humasClientIdtest",
};
const res = await getGenerateRewriter(request);
close();
if (res?.error) {
error("Error");
}
setArticleIds([...articleIds, res?.data?.data?.id]);
};
useEffect(() => {
getArticleDetail();
}, [selectedId]);
const checkArticleStatus = async (data: string | null) => {
if (data === null) {
delay(7000).then(() => {
getArticleDetail();
});
}
};
const getArticleDetail = async () => {
if (selectedId) {
const res = await getDetailArticle(selectedId);
const data = res?.data?.data;
checkArticleStatus(data?.articleBody);
if (data?.articleBody !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content({
id: data?.id,
articleBody: "",
title: "",
metaTitle: "",
description: "",
metaDescription: "",
additionalKeywords: "",
mainKeyword: "",
});
}
}
};
return (
<fieldset>
<form className="flex flex-col w-full mt-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
{/* <Select
label="Writing Style"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedWritingSyle]}
onChange={(e) =>
e.target.value !== ""
? setSelectedWritingStyle(e.target.value)
: ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
{writingStyle.map((style) => (
<SelectItem key={style.name}>{style.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<Select
value={selectedWritingSyle}
onValueChange={(value) => setSelectedWritingStyle(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{writingStyle.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* <Select
label="Article Size"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedArticleSize]}
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
{articleSize.map((size) => (
<SelectItem key={size.value}>{size.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<Select
value={selectedArticleSize}
onValueChange={(value) => setSelectedArticleSize(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{articleSize.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* <Select
label="Bahasa"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedLanguage]}
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
<SelectItem key="id">Indonesia</SelectItem>
<SelectItem key="en">English</SelectItem>
</SelectSection>
</Select> */}
<Select
value={selectedLanguage}
onValueChange={(value) => setSelectedLanguage(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col mt-3">
<div className="flex flex-row gap-2 items-center">
<p className="text-sm">Text</p>
</div>
<div className="w-[78vw] lg:w-full">
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
</div>
{mainKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)}
{articleIds.length < 3 && (
<Button
onClick={onSubmit}
type="button"
disabled={mainKeyword === "" || isLoading}
className="my-5 w-full py-5 text-xs md:text-base"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
"Generate"
)}
</Button>
)}
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1 mt-2">
{articleIds?.map((id, index) => (
<Button
type="button"
key={id}
onClick={() => setSelectedId(id)}
disabled={isLoading && selectedId === id}
variant={selectedId === id ? "default" : "outline"}
className="flex items-center gap-2"
>
{isLoading && selectedId === id ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
`Article ${index + 1}`
)}
</Button>
))}
</div>
)}
{!isLoading && (
<div>
<GetSeoScore id={String(selectedId)} />
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,451 @@
"use client";
import { useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { delay } from "@/utils/global";
import GetSeoScore from "./get-seo-score-form";
import {
generateDataArticle,
getDetailArticle,
getGenerateKeywords,
getGenerateTitle,
} from "@/service/generate-article";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
const writingStyle = [
{
id: 1,
name: "Friendly",
},
{
id: 1,
name: "Professional",
},
{
id: 3,
name: "Informational",
},
{
id: 4,
name: "Neutral",
},
{
id: 5,
name: "Witty",
},
];
const articleSize = [
{
id: 1,
name: "News (300 - 900 words)",
value: "News",
},
{
id: 2,
name: "Info (900 - 2000 words)",
value: "Info",
},
{
id: 3,
name: "Detail (2000 - 5000 words)",
value: "Detail",
},
];
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
export default function GenerateSingleArticleForm(props: {
content: (data: DiseData) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] = useState("");
const [selectedArticleSize, setSelectedArticleSize] = useState("");
const [selectedLanguage, setSelectedLanguage] = useState("");
const [mainKeyword, setMainKeyword] = useState("");
const [title, setTitle] = useState("");
const [additionalKeyword, setAdditionalKeyword] = useState("");
const [articleIds, setArticleIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number>();
const [isLoading, setIsLoading] = useState(false);
const generateAll = async (keyword: string | undefined) => {
if (keyword) {
generateTitle(keyword);
generateKeywords(keyword);
}
};
const generateTitle = async (keyword: string | undefined) => {
if (keyword) {
loading();
const req = {
keyword: keyword,
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "None",
clientId: "",
};
const res = await getGenerateTitle(req);
const data = res?.data?.data;
setTitle(data);
close();
}
};
const generateKeywords = async (keyword: string | undefined) => {
if (keyword) {
const req = {
keyword: keyword,
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "0",
clientId: "",
};
loading();
const res = await getGenerateKeywords(req);
const data = res?.data?.data;
setAdditionalKeyword(data);
close();
}
};
const onSubmit = async () => {
loading();
const request = {
advConfig: "",
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "None",
title: title,
imageSource: "Web",
mainKeyword: mainKeyword,
additionalKeywords: additionalKeyword,
targetCountry: null,
articleSize: selectedArticleSize,
projectId: 2,
createdBy: "123123",
clientId: "humasClientIdtest",
};
const res = await generateDataArticle(request);
close();
if (res?.error) {
error("Error");
}
setArticleIds([...articleIds, res?.data?.data?.id]);
// props.articleId(res?.data?.data?.id);
};
useEffect(() => {
getArticleDetail();
}, [selectedId]);
const checkArticleStatus = async (data: string | null) => {
if (data === null) {
delay(7000).then(() => {
getArticleDetail();
});
}
};
const getArticleDetail = async () => {
if (selectedId) {
const res = await getDetailArticle(selectedId);
const data = res?.data?.data;
checkArticleStatus(data?.articleBody);
if (data?.articleBody !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content({
id: data?.id,
articleBody: "",
title: "",
metaTitle: "",
description: "",
metaDescription: "",
additionalKeywords: "",
mainKeyword: "",
});
}
}
};
return (
<fieldset>
<form className="flex flex-col w-full mt-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
{/* <Select
label="Writing Style"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedWritingSyle]}
onChange={(e) =>
e.target.value !== ""
? setSelectedWritingStyle(e.target.value)
: ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
{writingStyle.map((style) => (
<SelectItem key={style.name}>{style.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<Select
value={selectedWritingSyle}
onValueChange={(value) => {
if (value !== "") setSelectedWritingStyle(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{writingStyle.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* <Select
label="Article Size"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedArticleSize]}
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
{articleSize.map((size) => (
<SelectItem key={size.value}>{size.name}</SelectItem>
))}
</SelectSection>
</Select> */}
<Select
value={selectedArticleSize}
onValueChange={(value) => {
if (value !== "") setSelectedArticleSize(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Article Size" />
</SelectTrigger>
<SelectContent>
{articleSize.map((style) => (
<SelectItem key={style.name} value={style.value}>
{style.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* <Select
label="Bahasa"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedLanguage]}
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
}}
>
<SelectSection>
<SelectItem key="id">Indonesia</SelectItem>
<SelectItem key="en">English</SelectItem>
</SelectSection>
</Select> */}
<Select
value={selectedLanguage}
onValueChange={(value) => {
if (value !== "") setSelectedLanguage(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col mt-3">
<div className="flex flex-row gap-2 items-center">
<p className="text-sm">Main Keyword</p>
<Button
type="button"
variant="default"
size="sm"
onClick={() => generateAll(mainKeyword)}
disabled={isLoading} // tambahkan state kontrol loading
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
"Process"
)}
</Button>
</div>
<Input
type="text"
id="mainKeyword"
placeholder="Masukkan keyword utama"
value={mainKeyword}
onChange={(e) => setMainKeyword(e.target.value)}
className="w-full mt-1 border border-gray-300 rounded-lg dark:border-gray-400"
/>
{mainKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)}
<div className="flex flex-row gap-2 items-center mt-3">
<p className="text-sm">Title</p>
<Button
type="button"
variant="default"
size="sm"
onClick={() => generateTitle(mainKeyword)}
disabled={mainKeyword === ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="title"
placeholder=""
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full mt-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" // Custom styling using className
aria-label="Title"
/>
{/* {title == "" && <p className="text-red-400 text-sm">Required</p>} */}
<div className="flex flex-row gap-2 items-center mt-2">
<p className="text-sm">Additional Keyword</p>
<Button
type="button"
className="text-sm"
size="sm"
onClick={() => generateKeywords(mainKeyword)}
disabled={mainKeyword === ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="additionalKeyword"
placeholder=""
value={additionalKeyword}
onChange={(e) => setAdditionalKeyword(e.target.value)}
className="mt-1 border rounded-lg dark:bg-transparent dark:border-gray-400"
aria-label="Additional Keyword"
/>
{/* {additionalKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)} */}
{/* {articleIds.length < 3 && (
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == "" || title == "" || additionalKeyword == ""}>
Generate
</Button>
)} */}
{articleIds.length < 3 && (
<Button
className="my-5 w-full py-5 text-xs md:text-base"
type="button"
onClick={onSubmit}
disabled={
mainKeyword === "" || title === "" || additionalKeyword === ""
}
>
Generate
</Button>
)}
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1 mt-2">
{articleIds.map((id, index) => (
<Button
type="button"
key={id}
onClick={() => setSelectedId(id)}
disabled={isLoading && selectedId === id}
className={`
${
selectedId === id
? isLoading
? "bg-yellow-500"
: "bg-green-600"
: "bg-gray-200"
}
text-sm px-4 py-2 rounded text-white transition-colors
`}
>
Article {index + 1}
</Button>
))}
</div>
)}
{!isLoading && (
<div>
<GetSeoScore id={String(selectedId)} />
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { CustomCircularProgress } from "@/components/layout/costum-circular-progress";
import { getSeoScore } from "@/service/generate-article";
import { useEffect, useState } from "react";
export default function GetSeoScore(props: { id: string }) {
useEffect(() => {
fetchSeoScore();
}, [props.id]);
const [totalScoreSEO, setTotalScoreSEO] = useState();
const [errorSEO, setErrorSEO] = useState<any>([]);
const [warningSEO, setWarningSEO] = useState<any>([]);
const [optimizedSEO, setOptimizedSEO] = useState<any>([]);
const fetchSeoScore = async () => {
const res = await getSeoScore(props?.id);
if (res.error) {
// error(res.message);
return false;
}
setTotalScoreSEO(res.data.data?.seo_analysis?.score || 0);
const errorList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.error,
...res.data.data?.seo_analysis?.analysis?.content_quality?.error,
];
setErrorSEO(errorList);
const warningList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.warning,
...res.data.data?.seo_analysis?.analysis?.content_quality?.warning,
];
setWarningSEO(warningList);
const optimizedList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.optimized,
...res.data.data?.seo_analysis?.analysis?.content_quality?.optimized,
];
setOptimizedSEO(optimizedList);
};
return (
<div className="overflow-y-auto my-2">
<div className="text-black flex flex-col rounded-md gap-3">
<p className="font-semibold text-lg"> SEO Score</p>
{totalScoreSEO ? (
<div className="flex flex-row gap-5 w-full">
{/* <CircularProgress
aria-label=""
color="warning"
showValueLabel={true}
size="lg"
value={Number(totalScoreSEO) * 100}
/> */}
<CustomCircularProgress value={Number(totalScoreSEO) * 100} />
<div>
{/* <ApexChartDonut value={Number(totalScoreSEO) * 100} /> */}
</div>
<div className="flex flex-row gap-5">
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-red-500 rounded-lg">
{/* <TimesIcon size={15} className="text-danger" /> */}
Error : {errorSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-yellow-500 rounded-lg">
{/* <p className="text-warning w-[15px] h-[15px] text-center mt-[-10px]">
!
</p> */}
Warning : {warningSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-green-500 rounded-lg">
{/* <CheckIcon size={15} className="text-success" /> */}
Optimize : {optimizedSEO.length || 0}
</div>
</div>
</div>
) : (
"Belum ada Data"
)}
{totalScoreSEO && (
// <Accordion
// variant="splitted"
// itemClasses={{
// base: "!bg-transparent",
// title: "text-black",
// }}
// >
// <AccordionItem
// key="1"
// aria-label="Error"
// // startContent={<TimesIcon size={20} className="text-danger" />}
// title={`${errorSEO?.length || 0} Errors`}
// >
// <div className="flex flex-col gap-2">
// {errorSEO?.map((item: any) => (
// <p key={item} className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
// {item}
// </p>
// ))}
// </div>
// </AccordionItem>
// <AccordionItem
// key="2"
// aria-label="Warning"
// // startContent={
// // <p className="text-warning w-[20px] h-[20px] text-center mt-[-10px]">
// // !
// // </p>
// // }
// title={`${warningSEO?.length || 0} Warnings`}
// >
// <div className="flex flex-col gap-2">
// {warningSEO?.map((item: any) => (
// <p key={item} className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
// {item}
// </p>
// ))}
// </div>
// </AccordionItem>
// <AccordionItem
// key="3"
// aria-label="Optimized"
// // startContent={<CheckIcon size={20} className="text-success" />}
// title={`${optimizedSEO?.length || 0} Optimized`}
// >
// <div className="flex flex-col gap-2">
// {optimizedSEO?.map((item: any) => (
// <p key={item} className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
// {item}
// </p>
// ))}
// </div>
// </AccordionItem>
// </Accordion>
<Accordion type="multiple" className="w-full">
<AccordionItem value="error">
<AccordionTrigger className="text-black">{`${
errorSEO?.length || 0
} Errors`}</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
{errorSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="warning">
<AccordionTrigger className="text-black">{`${
warningSEO?.length || 0
} Warnings`}</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
{warningSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="optimized">
<AccordionTrigger className="text-black">{`${
optimizedSEO?.length || 0
} Optimized`}</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
{optimizedSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,442 @@
"use client";
import { close, error, loading } from "@/config/swal";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { z } from "zod";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { editMasterUsers, getDetailMasterUsers } from "@/service/master-user";
import { getAllUserLevels } from "@/service/user-levels-service";
import { listUserRole } from "@/service/master-user-role";
import { Card } from "../ui/card";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "../ui/button";
const userSchema = z.object({
id: z.number(),
label: z.string(),
value: z.string(),
});
const masterUserSchema = z.object({
fullname: z.string().min(1, { message: "Required" }),
username: z.string().min(1, { message: "Required" }),
email: z.string().min(1, { message: "Required" }),
identityNumber: z.string().min(1, { message: "Required" }),
genderType: z.string().min(1, { message: "Required" }),
phoneNumber: z.string().min(1, { message: "Required" }),
address: z.string().min(1, { message: "Required" }),
userLevelType: userSchema,
userRoleType: userSchema,
});
export default function FormMasterUserEdit() {
const router = useRouter();
const MySwal = withReactContent(Swal);
const animatedComponents = makeAnimated();
const params = useParams();
const id = params?.id;
const [parentList, setParentList] = useState<any>([]);
const [listRole, setListRole] = useState<any>([]);
const formOptions = {
resolver: zodResolver(masterUserSchema),
};
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
} = useForm<MicroIssueSchema>(formOptions);
async function save(data: z.infer<typeof masterUserSchema>) {
const formData = {
address: data.address,
email: data.email,
fullname: data.fullname,
genderType: data.genderType,
identityNumber: data.identityNumber,
phoneNumber: data.phoneNumber,
userLevelId: data.userLevelType.id,
userRoleId: data.userRoleType.id,
username: data.username,
};
const response = await editMasterUsers(formData, String(id));
if (response?.error) {
error(response.message);
return false;
}
successSubmit("/admin/master-user");
}
function successSubmit(redirect: any) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
MySwal.fire({
title: "Simpan Data",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(data);
}
});
}
useEffect(() => {
initFetch();
}, [id]);
const initFetch = async () => {
loading();
const res = await getDetailMasterUsers(String(id));
const profile = res?.data?.data;
const listLevel = await fetchUserLevel();
const listRole = await fetchUserRole();
const findLevel = listLevel?.find((a) => a.id === profile.userLevelId);
const findRole = listRole?.find((a) => a.id === profile.userRoleId);
setValue("fullname", profile?.fullname);
setValue("username", profile?.username);
setValue("email", profile?.email);
setValue("address", profile?.address);
setValue("identityNumber", profile?.identityNumber);
setValue("genderType", profile?.genderType);
setValue("phoneNumber", profile?.phoneNumber);
if (findLevel) {
setValue("userLevelType", findLevel);
}
if (findRole) {
setValue("userRoleType", findRole);
}
close();
};
const fetchUserLevel = async () => {
const res = await getAllUserLevels();
if (res?.data?.data) {
return setupParent(res?.data?.data, "level");
}
};
const fetchUserRole = async () => {
const request = {
limit: 100,
page: 1,
};
const res = await listUserRole(request);
if (res?.data?.data) {
return setupParent(res?.data?.data, "role");
}
};
const setupParent = (data: any, type: "level" | "role") => {
const temp = [];
for (const element of data) {
temp.push({
id: element.id,
label: element.name,
value: element.aliasName || element.code,
});
}
if (type === "level") {
setParentList(temp);
} else {
setListRole(temp);
}
return temp;
};
return (
<div className="mx-5 my-5 overflow-y-auto">
<form
method="POST"
onSubmit={handleSubmit(onSubmit)}
className="w-full lg:w-1/2 lg:ml-4"
>
<Card className="rounded-md p-5 flex flex-col gap-3">
<Controller
control={control}
name="fullname"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="title">Nama Lengkap</Label>
<Input
type="text"
id="title"
placeholder="Nama Lengkap..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
/>
</div>
)}
/>
{errors.fullname?.message && (
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
)}
<Controller
control={control}
name="username"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="username">Username</Label>
<Input
type="text"
id="username"
placeholder="Username..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
/>
</div>
)}
/>
{errors.username?.message && (
<p className="text-red-400 text-sm">{errors.username?.message}</p>
)}
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
placeholder="Email..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
/>
</div>
)}
/>
{errors.email?.message && (
<p className="text-red-400 text-sm">{errors.email?.message}</p>
)}
{/* <Controller
control={control}
name="identityType"
render={({ field: { onChange, value } }) => (
<Select
variant="bordered"
labelPlacement="outside"
label="Identity Type"
placeholder="Select"
className="max-w-xs"
selectedKeys={[value]}
onChange={onChange}
>
{typeIdentity.map((type) => (
<SelectItem key={type.value}>{type.value}</SelectItem>
))}
</Select>
)}
/>
{errors.identityType?.message && (
<p className="text-red-400 text-sm">
{errors.identityType?.message}
</p>
)} */}
<Controller
control={control}
name="identityNumber"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="identityNumber">NRP</Label>
<Input
type="number"
id="identityNumber"
placeholder="NRP..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
/>
</div>
)}
/>
{errors.identityNumber?.message && (
<p className="text-red-400 text-sm">
{errors.identityNumber?.message}
</p>
)}
<Controller
control={control}
name="address"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="alamat">Alamat</Label>
<Textarea
id="alamat"
placeholder="Alamat..."
value={value ?? ""}
onChange={(e) => onChange(e)}
className="border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
/>
</div>
)}
/>
{errors.address?.message && (
<p className="text-red-400 text-sm">{errors.address?.message}</p>
)}
<Controller
control={control}
name="genderType"
render={({ field: { onChange, value } }) => (
<div className="space-y-2">
<Label htmlFor="gender">Gender</Label>
<RadioGroup
id="gender"
value={value}
onValueChange={onChange}
className="flex flex-row gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Male" id="male" />
<Label htmlFor="male">Laki-laki</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Female" id="female" />
<Label htmlFor="female">Perempuan</Label>
</div>
</RadioGroup>
</div>
)}
/>
{errors.genderType?.message && (
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
)}
<Controller
control={control}
name="userLevelType"
render={({ field: { onChange, value } }) => (
<>
<p className="text-sm mt-3">Level Pengguna</p>
<ReactSelect
className="basic-single text-black z-50"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
value={value}
onChange={onChange}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={false}
placeholder=""
name="sub-module"
options={parentList}
/>
</>
)}
/>
{errors.userLevelType?.message && (
<p className="text-red-400 text-sm">
{errors.userLevelType?.message}
</p>
)}
<Controller
control={control}
name="userRoleType"
render={({ field: { onChange, value } }) => (
<>
<p className="text-sm mt-3">Peran Pengguna</p>
<ReactSelect
className="basic-single text-black z-49"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
value={value}
onChange={onChange}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={false}
placeholder=""
name="sub-module"
options={listRole}
/>
</>
)}
/>
{errors.userRoleType?.message && (
<p className="text-red-400 text-sm">
{errors.userRoleType?.message}
</p>
)}
<Controller
control={control}
name="phoneNumber"
render={({ field: { onChange, value } }) => (
<div className="w-full z-0 space-y-2">
<Label htmlFor="identityNumber">No. Handphone</Label>
<Input
type="number"
id="identityNumber"
placeholder="08*********"
value={value ?? ""}
onChange={onChange}
/>
</div>
)}
/>
{errors.phoneNumber?.message && (
<p className="text-red-400 text-sm">
{errors.phoneNumber?.message}
</p>
)}
<div className="flex justify-end gap-3">
<Link href={`/admin/master-user`}>
<Button color="danger" variant="ghost">
Cancel
</Button>
</Link>
<Button type="submit" color="primary" variant="default">
Save
</Button>
</div>
</Card>
</form>
</div>
);
}

View File

@ -0,0 +1,577 @@
"use client";
import { close, error, loading } from "@/config/swal";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { z } from "zod";
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
import ReactPasswordChecklist from "react-password-checklist";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { zodResolver } from "@hookform/resolvers/zod";
import { createMasterUser } from "@/service/master-user";
import { getAllUserLevels } from "@/service/user-levels-service";
import { listUserRole } from "@/service/master-user-role";
import { Card } from "../ui/card";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "../ui/button";
const userSchema = z.object({
id: z.number(),
label: z.string(),
value: z.string(),
});
const masterUserSchema = z.object({
fullname: z.string().min(1, { message: "Required" }),
username: z.string().min(1, { message: "Required" }),
password: z
.string()
.min(8, "Password harus memiliki minimal 8 karakter.")
.refine((password) => /[A-Z]/.test(password), {
message: "Password harus memiliki minimal satu huruf kapital.",
})
.refine((password) => /[0-9]/.test(password), {
message: "Password harus memiliki minimal satu angka.",
})
.refine((password) => /[!@#$%^&*(),.?":{}|<>]/.test(password), {
message: "Password harus memiliki minimal satu simbol.",
}),
passwordValidate: z.string().min(1, { message: "Required" }),
email: z.string().min(1, { message: "Required" }),
identityNumber: z.string().min(1, { message: "Required" }),
genderType: z.string().min(1, { message: "Required" }),
phoneNumber: z.string().min(1, { message: "Required" }),
address: z.string().min(1, { message: "Required" }),
userLevelType: userSchema,
userRoleType: userSchema,
});
export default function FormMasterUser() {
const router = useRouter();
const animatedComponents = makeAnimated();
const MySwal = withReactContent(Swal);
const [isVisible, setIsVisible] = useState([false, false]);
const [isValidPassword, setIsValidPassword] = useState(false);
const [parentList, setParentList] = useState<any>([]);
const [listRole, setListRole] = useState<any>([]);
const toggleVisibility = (type: number) => {
setIsVisible(
type === 0 ? [!isVisible[0], isVisible[1]] : [isVisible[0], !isVisible[1]]
);
};
const formOptions = {
resolver: zodResolver(masterUserSchema),
defaultValues: { password: "", passwordValidate: "" },
};
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
const {
control,
handleSubmit,
formState: { errors },
setError,
watch,
setValue,
} = useForm<MicroIssueSchema>(formOptions);
const passwordVal = watch("password");
const passwordConfVal = watch("passwordValidate");
async function save(data: z.infer<typeof masterUserSchema>) {
const formData = {
address: data.address,
password: data.password,
email: data.email,
fullname: data.fullname,
genderType: data.genderType,
identityNumber: data.identityNumber,
identityType: "nrp",
phoneNumber: data.phoneNumber,
userLevelId: data.userLevelType.id,
userRoleId: data.userRoleType.id,
username: data.username,
};
const response = await createMasterUser(formData);
if (response?.error) {
error(response.message);
return false;
}
successSubmit("/admin/master-user");
}
function successSubmit(redirect: any) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
if (data.password === data.passwordValidate) {
MySwal.fire({
title: "Simpan Data",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(data);
}
});
} else {
setError("passwordValidate", {
type: "manual",
message: "Password harus sama.",
});
}
}
const generatePassword = () => {
const length = Math.floor(Math.random() * 9) + 8;
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
const numberChars = "0123456789";
const specialChars = "!@#$%^&*";
const allChars =
upperCaseChars + lowerCaseChars + numberChars + specialChars;
let generatedPassword = "";
generatedPassword +=
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
generatedPassword +=
specialChars[Math.floor(Math.random() * specialChars.length)];
generatedPassword +=
numberChars[Math.floor(Math.random() * numberChars.length)];
generatedPassword +=
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
for (let i = generatedPassword.length; i < length; i++) {
generatedPassword +=
allChars[Math.floor(Math.random() * allChars.length)];
}
generatedPassword = generatedPassword
.split("")
.sort(() => 0.5 - Math.random())
.join("");
setValue("password", generatedPassword);
};
useEffect(() => {
fetchUserLevel();
fetchUserRole();
}, []);
const fetchUserLevel = async () => {
loading();
const res = await getAllUserLevels();
close();
if (res?.data?.data) {
setupParent(res?.data?.data, "level");
}
};
const fetchUserRole = async () => {
loading();
const request = {
limit: 100,
page: 1,
};
const res = await listUserRole(request);
close();
if (res?.data?.data) {
setupParent(res?.data?.data, "role");
}
};
const setupParent = (data: any, type: "level" | "role") => {
const temp = [];
for (const element of data) {
temp.push({
id: element.id,
label: element.name,
value: element.aliasName || element.code,
});
}
if (type === "level") {
setParentList(temp);
} else {
setListRole(temp);
}
};
return (
<div className="mx-5 my-5 overflow-y-auto">
<form method="POST" onSubmit={handleSubmit(onSubmit)}>
<Card className="rounded-md p-5 flex flex-col gap-3">
<Controller
control={control}
name="fullname"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label
htmlFor="title"
className="mb-1 block text-sm font-medium"
>
Nama Lengkap
</Label>
<Input
type="text"
id="title"
placeholder="Nama Lengkap..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
/>
</div>
)}
/>
{errors.fullname?.message && (
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
)}
<Controller
control={control}
name="username"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label
htmlFor="username"
className="mb-1 block text-sm font-medium"
>
Username
</Label>
<Input
type="text"
id="username"
placeholder="Username..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
/>
</div>
)}
/>
{errors.username?.message && (
<p className="text-red-400 text-sm">{errors.username?.message}</p>
)}
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label
htmlFor="email"
className="mb-1 block text-sm font-medium"
>
Email
</Label>
<Input
type="email"
id="email"
placeholder="Email..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
/>
</div>
)}
/>
{errors.email?.message && (
<p className="text-red-400 text-sm">{errors.email?.message}</p>
)}
<Controller
control={control}
name="identityNumber"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label
htmlFor="identityNumber"
className="mb-1 block text-sm font-medium"
>
NRP
</Label>
<Input
type="number"
id="identityNumber"
placeholder="NRP..."
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
/>
</div>
)}
/>
{errors.identityNumber?.message && (
<p className="text-red-400 text-sm">
{errors.identityNumber?.message}
</p>
)}
<Controller
control={control}
name="address"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label
htmlFor="alamat"
className="mb-1 block text-sm font-medium"
>
Alamat
</Label>
<Textarea
id="alamat"
placeholder="Alamat..."
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
/>
</div>
)}
/>
{errors.address?.message && (
<p className="text-red-400 text-sm">{errors.address?.message}</p>
)}
<Controller
control={control}
name="genderType"
render={({ field: { onChange, value } }) => (
<div className="w-full">
<Label className="mb-2 block text-sm font-medium">Gender</Label>
<RadioGroup
className="flex flex-row gap-4"
value={value}
onValueChange={onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Male" id="male" />
<Label htmlFor="male">Laki-laki</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Female" id="female" />
<Label htmlFor="female">Perempuan</Label>
</div>
</RadioGroup>
</div>
)}
/>
{errors.genderType?.message && (
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
)}
<Controller
control={control}
name="phoneNumber"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="identityNumber">No. Handphone</Label>
<Input
type="number"
id="identityNumber"
placeholder="08*********"
value={value ?? ""}
onChange={onChange}
className="w-full"
/>
</div>
)}
/>
{errors.phoneNumber?.message && (
<p className="text-red-400 text-sm">
{errors.phoneNumber?.message}
</p>
)}
<Controller
control={control}
name="userLevelType"
render={({ field: { onChange, value } }) => (
<>
<p className="text-sm mt-3">Level Pengguna</p>
<ReactSelect
className="basic-single text-black z-50"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
onChange={onChange}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={false}
placeholder=""
name="sub-module"
options={parentList}
/>
</>
)}
/>
{errors.userLevelType?.message && (
<p className="text-red-400 text-sm">
{errors.userLevelType?.message}
</p>
)}
<Controller
control={control}
name="userRoleType"
render={({ field: { onChange, value } }) => (
<>
<p className="text-sm mt-3">Peran Pengguna</p>
<ReactSelect
className="basic-single text-black z-49"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
onChange={onChange}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={false}
placeholder=""
name="sub-module"
options={listRole}
/>
</>
)}
/>
{errors.userRoleType?.message && (
<p className="text-red-400 text-sm">
{errors.userRoleType?.message}
</p>
)}
<Controller
control={control}
name="password"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2 relative z-0">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
type={isVisible[0] ? "text" : "password"}
id="password"
placeholder="Password..."
value={value ?? ""}
onChange={onChange}
className="w-full pr-10"
/>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
type="button"
onClick={() => toggleVisibility(0)}
>
{isVisible[0] ? (
<EyeSlashFilledIcon className="pointer-events-none" />
) : (
<EyeFilledIcon className="pointer-events-none" />
)}
</button>
</div>
</div>
)}
/>
{errors.password?.message && (
<p className="text-red-400 text-sm">{errors.password?.message}</p>
)}
<Controller
control={control}
name="passwordValidate"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2 relative z-0">
<Label htmlFor="passwordValidate">Konfirmasi Password</Label>
<div className="relative">
<Input
type={isVisible[1] ? "text" : "password"}
id="passwordValidate"
placeholder="Konfirmasi Password..."
value={value ?? ""}
onChange={onChange}
className="w-full pr-10"
/>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
type="button"
onClick={() => toggleVisibility(1)}
>
{isVisible[1] ? (
<EyeSlashFilledIcon className="pointer-events-none" />
) : (
<EyeFilledIcon className="pointer-events-none" />
)}
</button>
</div>
</div>
)}
/>
{errors.passwordValidate?.message && (
<p className="text-red-400 text-sm">
{errors.passwordValidate?.message}
</p>
)}
<a
className="cursor-pointer text-[#DD8306]"
onClick={generatePassword}
>
Generate Password
</a>
<ReactPasswordChecklist
rules={["minLength", "specialChar", "number", "capital", "match"]}
minLength={8}
value={passwordVal}
valueAgain={passwordConfVal}
onChange={(isValid) => {
setIsValidPassword(isValid);
}}
className="text-black dark:text-white text-sm my-3"
messages={{
minLength: "Password must be more than 8 characters",
specialChar: "Password must include a special character",
number: "Password must include a number",
capital: "Password must include an uppercase letter",
match: "Passwords match",
}}
/>
<div className="flex justify-end gap-3">
<Link href={`/admin/master-user`}>
<Button color="danger" variant="ghost">
Cancel
</Button>
</Link>
<Button type="submit" color="primary" variant="default">
Save
</Button>
</div>
</Card>
</form>
</div>
);
}

529
components/form/login.tsx Normal file
View File

@ -0,0 +1,529 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import Cookies from "js-cookie";
import { close, error, loading } from "@/config/swal";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import {
checkUsernames,
emailValidation,
getProfile,
postSignIn,
setupEmail,
} from "@/service/master-user";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
import { saveActivity } from "@/service/activity-log";
export default function Login() {
const router = useRouter();
const [isVisible, setIsVisible] = useState(false);
const [isVisibleSetup, setIsVisibleSetup] = useState([false, false]);
const [oldEmail, setOldEmail] = useState("");
const [newEmail, setNewEmail] = useState("");
const [passwordSetup, setPasswordSetup] = useState("");
const [confPasswordSetup, setConfPasswordSetup] = useState("");
const toggleVisibility = () => setIsVisible(!isVisible);
const [needOtp, setNeedOtp] = useState(false);
const [isFirstLogin, setFirstLogin] = useState(false);
const [otpValue, setOtpValue] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [accessData, setAccessData] = useState<any>();
const [profile, setProfile] = useState<any>();
const [isValidEmail, setIsValidEmail] = useState(false);
const [isResetPassword, setIsResetPassword] = useState(false);
const [checkUsernameValue, setCheckUsernameValue] = useState("");
const MySwal = withReactContent(Swal);
const setValUsername = (e: any) => {
const uname = e.replaceAll(/[^\w.-]/g, "");
setUsername(uname.toLowerCase());
};
const onSubmit = async () => {
const data = {
username: username,
password: password,
};
if (!username || !password) {
error("Username & Password Wajib Diisi !");
} else {
loading();
const response = await postSignIn(data);
if (response?.error) {
error("Username / Password Tidak Sesuai");
} else {
const profile = await getProfile(response?.data?.data?.access_token);
const dateTime: any = new Date();
const newTime: any = dateTime.getTime() + 10 * 60 * 1000;
Cookies.set("access_token", response?.data?.data?.access_token, {
expires: 1,
});
Cookies.set("refresh_token", response?.data?.data?.refresh_token, {
expires: 1,
});
Cookies.set("time_refresh", newTime, {
expires: 1,
});
Cookies.set("is_first_login", "true", {
secure: true,
sameSite: "strict",
});
await saveActivity(
{
activityTypeId: 1,
url: "https://dev.mikulnews.com/auth",
userId: profile?.data?.data?.id,
},
response?.data?.data?.access_token
);
Cookies.set("profile_picture", profile?.data?.data?.profilePictureUrl, {
expires: 1,
});
Cookies.set("uie", profile?.data?.data?.id, {
expires: 1,
});
Cookies.set("ufne", profile?.data?.data?.fullname, {
expires: 1,
});
Cookies.set("ulie", profile?.data?.data?.userLevelGroup, {
expires: 1,
});
Cookies.set("username", profile?.data?.data?.username, {
expires: 1,
});
Cookies.set("fullname", profile?.data?.data?.fullname, {
expires: 1,
});
Cookies.set("urie", profile?.data?.data?.roleId, {
expires: 1,
});
Cookies.set("roleName", profile?.data?.data?.roleName, {
expires: 1,
});
Cookies.set("masterPoldaId", profile?.data?.data?.masterPoldaId, {
expires: 1,
});
Cookies.set("ulne", profile?.data?.data?.userLevelId, {
expires: 1,
});
Cookies.set("urce", profile?.data?.data?.roleCode, {
expires: 1,
});
Cookies.set("email", profile?.data?.data?.email, {
expires: 1,
});
router.push("/admin/dashboard");
Cookies.set("status", "login", {
expires: 1,
});
close();
}
}
};
const checkUsername = async () => {
const res = await checkUsernames(checkUsernameValue);
if (res?.error) {
error("Username tidak ditemukan");
return false;
}
MySwal.fire({
title: "",
text: "",
html: (
<>
<p>
Kami telah mengirimkan tautan untuk mengatur ulang kata sandi ke
email Anda
</p>
<p className="text-xs">
Apakah Anda sudah menerima emailnya? Jika belum, periksa folder spam
Anda
</p>
</>
),
icon: "info",
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Oke",
}).then((result) => {
if (result.isConfirmed) {
}
});
};
const submitCheckEmail = async () => {
const req = {
oldEmail: oldEmail,
newEmail: newEmail,
username: username,
password: password,
};
const res = await setupEmail(req);
if (res?.error) {
if (res.message?.messages[0]) {
error(res.message?.messages[0]);
} else {
error(res?.message);
}
return false;
}
close();
setNeedOtp(true);
setFirstLogin(false);
};
return (
<div className="min-h-screen flex">
{/* Left Side - Logo Section */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-green-600 via-green-700 to-green-800 relative overflow-hidden">
<div className="absolute inset-0 bg-black/20"></div>
<div className="relative z-10 flex items-center justify-center w-full p-12">
<div className="text-center">
<Link href={"/"}>
<div className="bg-white/10 backdrop-blur-sm rounded-2xl border ">
<img
src="/Warga.png"
alt="Warga Bicara Logo"
className="max-w-xl h-auto drop-shadow-lg pl-20"
/>
</div>
</Link>
<div className="mt-8 text-white/90">
<h2 className="text-2xl font-bold mb-2">Portal Warga Bicara</h2>
<p className="text-sm opacity-80">
Platform berita terpercaya untuk informasi terkini
</p>
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-xl"></div>
<div className="absolute bottom-20 right-20 w-32 h-32 bg-white/5 rounded-full blur-2xl"></div>
</div>
{/* Right Side - Login Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-gray-50">
<div className="w-full max-w-md">
{/* Mobile Logo */}
<div className="lg:hidden text-center mb-8">
<Link href={"/"}>
<img
src="/Warga.png"
alt="Warga Bicara Logo"
className="h-12 mx-auto"
/>
</Link>
</div>
{isFirstLogin ? (
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Setup Akun
</h2>
<p className="text-gray-600">Lengkapi informasi email Anda</p>
</div>
<div className="space-y-6">
<div>
<Label
htmlFor="old-email"
className="text-sm font-medium text-gray-700 mb-2 block"
>
Email Lama
</Label>
<Input
id="old-email"
type="email"
required
placeholder="Masukkan email lama"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
value={oldEmail}
onChange={(e) => setOldEmail(e.target.value)}
/>
</div>
<div>
<Label
htmlFor="new-email"
className="text-sm font-medium text-gray-700 mb-2 block"
>
Email Baru
</Label>
<Input
id="new-email"
type="email"
required
placeholder="Masukkan email baru"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
</div>
<Button
size="lg"
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={submitCheckEmail}
>
Submit
</Button>
</div>
</div>
) : needOtp ? (
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Verifikasi OTP
</h2>
<p className="text-gray-600">
Masukkan kode OTP yang telah dikirim
</p>
</div>
</div>
) : isResetPassword ? (
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Reset Password
</h2>
<p className="text-gray-600">
Masukkan username untuk reset password
</p>
</div>
<div className="space-y-6">
<div>
<Label
htmlFor="reset-username"
className="text-sm font-medium text-gray-700 mb-2 block"
>
Username
</Label>
<Input
id="reset-username"
type="text"
required
placeholder="Masukkan username"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
value={checkUsernameValue}
onChange={(e) =>
setCheckUsernameValue(e.target.value.trim())
}
onPaste={(e) =>
setCheckUsernameValue(e.currentTarget.value.trim())
}
onCopy={(e) =>
setCheckUsernameValue(e.currentTarget.value.trim())
}
/>
</div>
<Button
size="lg"
className="w-full bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
onClick={checkUsername}
disabled={checkUsernameValue === ""}
>
Check Username
</Button>
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
<Link
href={`/`}
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
>
Beranda
</Link>
<button
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
onClick={() => setIsResetPassword(false)}
>
Kembali ke Login
</button>
</div>
</div>
</div>
) : (
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Selamat Datang
</h2>
<p className="text-gray-600">
Portal Warga Bicara - Platform berita terpercaya
</p>
</div>
<div className="space-y-6">
<div>
<Label
htmlFor="username"
className="text-sm font-medium text-gray-700 mb-2 block"
>
Username
</Label>
<Input
id="username"
required
type="text"
placeholder="Masukkan username"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
value={username}
onChange={(e) => setValUsername(e.target.value.trim())}
onPaste={(e) =>
setValUsername(e.currentTarget.value.trim())
}
onCopy={(e) => setValUsername(e.currentTarget.value.trim())}
/>
</div>
<div>
<Label
htmlFor="password"
className="text-sm font-medium text-gray-700 mb-2 block"
>
Password
</Label>
<div className="relative">
<Input
id="password"
required
type={isVisible ? "text" : "password"}
placeholder="Masukkan password"
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={toggleVisibility}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
>
{isVisible ? (
<EyeSlashFilledIcon className="w-5 h-5" />
) : (
<EyeFilledIcon className="w-5 h-5" />
)}
</button>
</div>
</div>
<Button
size="lg"
className="w-full bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
onClick={onSubmit}
>
Masuk ke Portal
</Button>
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
<Link
href={`/`}
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
>
Beranda
</Link>
<button
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
onClick={() => setIsResetPassword(true)}
>
Lupa Password?
</button>
</div>
</div>
{/* <div className="mt-8 p-4 bg-green-50 rounded-lg border border-green-100">
<div className="flex items-start">
<svg className="w-5 h-5 text-green-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-green-800">
<p className="font-medium mb-1">Informasi Portal</p>
<p className="text-green-700">Akses informasi terkini dan status permintaan informasi yang telah diajukan.</p>
</div>
</div>
</div> */}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import PasswordChecklist from "react-password-checklist";
import { close, error, loading } from "@/config/swal";
import { savePassword } from "@/service/master-user";
import { Input } from "@/components/ui/input";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function PasswordForm(props: { doFetch: () => void }) {
const MySwal = withReactContent(Swal);
const [isVisible, setIsVisible] = useState([false, false]);
const [passwordConf, setPasswordConf] = useState("");
const [password, setPassword] = useState("");
const [isValidPassword, setIsValidPassword] = useState(false);
const onSubmit = async () => {
loading();
const data = {
password: password,
confirmPassword: passwordConf,
};
const res = await savePassword(data);
if (res?.error) {
error(res.message);
return false;
}
close();
props.doFetch();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
}
});
};
const generatePassword = () => {
const length = Math.floor(Math.random() * 9) + 8;
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
const numberChars = "0123456789";
const specialChars = "!@#$%^&*";
const allChars =
upperCaseChars + lowerCaseChars + numberChars + specialChars;
let generatedPassword = "";
generatedPassword +=
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
generatedPassword +=
specialChars[Math.floor(Math.random() * specialChars.length)];
generatedPassword +=
numberChars[Math.floor(Math.random() * numberChars.length)];
generatedPassword +=
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
for (let i = generatedPassword.length; i < length; i++) {
generatedPassword +=
allChars[Math.floor(Math.random() * allChars.length)];
}
generatedPassword = generatedPassword
.split("")
.sort(() => 0.5 - Math.random())
.join("");
setPassword(generatedPassword);
};
return (
<form className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm">Password</p>
<div className="relative">
<Input
required
type={isVisible[0] ? "text" : "password"}
placeholder=""
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
value={password}
onChange={(e) => {
const target = e.target as HTMLInputElement;
setPassword(target.value.trim());
}}
onPaste={(e) => {
const target = e.target as HTMLInputElement;
setPassword(target.value.trim());
}}
onCopy={(e) => {
const target = e.target as HTMLInputElement;
setPassword(target.value.trim());
}}
/>
<button
type="button"
onClick={() => setIsVisible([!isVisible[0], isVisible[1]])}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
>
{isVisible[0] ? (
<EyeOff className="w-5 h-5 pointer-events-none" />
) : (
<Eye className="w-5 h-5 pointer-events-none" />
)}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Konfirmasi Password</p>
<div className="relative">
<Input
required
type={isVisible[1] ? "text" : "password"}
placeholder=""
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
value={passwordConf}
onChange={(e) => {
const target = e.target as HTMLInputElement;
setPasswordConf(target.value.trim());
}}
onPaste={(e) => {
const target = e.target as HTMLInputElement;
setPasswordConf(target.value.trim());
}}
onCopy={(e) => {
const target = e.target as HTMLInputElement;
setPasswordConf(target.value.trim());
}}
/>
<button
type="button"
onClick={() => setIsVisible([isVisible[0], !isVisible[1]])}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
>
{isVisible[1] ? (
<EyeOff className="w-5 h-5 pointer-events-none" />
) : (
<Eye className="w-5 h-5 pointer-events-none" />
)}
</button>
</div>
</div>
<a className="cursor-pointer text-[#DD8306]" onClick={generatePassword}>
Generate Password
</a>
<PasswordChecklist
rules={["minLength", "specialChar", "number", "capital", "match"]}
minLength={8}
value={password}
valueAgain={passwordConf}
onChange={(isValid) => {
setIsValidPassword(isValid);
}}
className="text-black dark:text-white text-sm my-3"
messages={{
minLength: "Password must be more than 8 characters",
specialChar: "Password must include a special character",
number: "Password must include a number",
capital: "Password must include an uppercase letter",
match: "Passwords match",
}}
/>
<Button
className="w-fit mt-4 bg-blue-600 text-white hover:bg-blue-700"
onClick={onSubmit}
disabled={!isValidPassword}
>
Simpan
</Button>
</form>
);
}

View File

@ -0,0 +1,290 @@
"use client";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { close, error, loading } from "@/config/swal";
import { updateProfile } from "@/service/master-user";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "@/components/ui/button";
const formSchema = z.object({
fullname: z.string().min(1, {
message: "Harus diisi",
}),
username: z.string().min(1, {
message: "Harus diisi",
}),
email: z
.string()
.email({
message: "Email tidak valid",
})
.min(2, {
message: "Harus diisi",
}),
nrp: z.string().min(1, {
message: "Harus diisi",
}),
address: z.string().min(1, {
message: "Harus diisi",
}),
gender: z.string().min(1, {
message: "Harus diisi",
}),
phoneNumber: z.string().min(1, {
message: "Harus diisi",
}),
});
export default function ProfileForm(props: {
profile: any;
doFetch: () => void;
}) {
const MySwal = withReactContent(Swal);
const { profile } = props;
const formOptions = {
resolver: zodResolver(formSchema),
};
type UserSettingSchema = z.infer<typeof formSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
setValue("fullname", profile?.fullname);
setValue("username", profile?.username);
setValue("email", profile?.email);
setValue("address", profile?.address);
setValue("nrp", profile?.identityNumber);
setValue("gender", profile?.genderType);
setValue("phoneNumber", profile?.phoneNumber);
}, [profile]);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
loading();
const req = {
address: values.address,
fullname: values.fullname,
username: values.username,
email: values.email,
identityNumber: values.nrp,
phoneNumber: values.phoneNumber,
genderType: values.gender,
userLevelId: profile.userLevelId,
userRoleId: profile.userRoleId,
};
const res = await updateProfile(req);
close();
if (res?.error) {
error(res.message);
return false;
}
props.doFetch();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
}
});
};
return (
<form className="flex flex-col gap-3 " onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-1">
<p className="text-sm">Username</p>
<Controller
control={control}
name="username"
render={({ field: { onChange, value } }) => (
<Input
type="text"
id="username"
placeholder=""
readOnly
value={value ?? ""}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400"
/>
)}
/>
{errors?.username && (
<p className="text-red-400 text-sm mb-3">
{errors.username?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="fullname"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<label htmlFor="fullname" className="text-sm">
Nama Lengkap
</label>
<Input
type="text"
id="fullname"
placeholder=""
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
/>
</div>
)}
/>
{errors?.fullname && (
<p className="text-red-400 text-sm mb-3">
{errors.fullname?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm">
Email
</label>
<Input
type="email"
id="email"
placeholder=""
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
/>
</div>
)}
/>
{errors?.email && (
<p className="text-red-400 text-sm mb-3">{errors.email?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="nrp"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<label htmlFor="nrp" className="text-sm">
NRP
</label>
<Input
type="number"
id="nrp"
placeholder=""
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
/>
</div>
)}
/>
{errors?.nrp && (
<p className="text-red-400 text-sm mb-3">{errors.nrp?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="address"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<label htmlFor="address" className="text-sm">
Alamat
</label>
<Textarea
id="address"
placeholder=""
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
/>
</div>
)}
/>
{errors?.address && (
<p className="text-red-400 text-sm mb-3">{errors.address?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Gender</p>
<Controller
control={control}
name="gender"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<RadioGroup
value={value}
onValueChange={onChange}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="male" />
<label htmlFor="male" className="text-sm">
Laki-laki
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="female" />
<label htmlFor="female" className="text-sm">
Perempuan
</label>
</div>
</RadioGroup>
</div>
)}
/>
{errors?.gender && (
<p className="text-red-400 text-sm mb-3">{errors.gender?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="phoneNumber"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1">
<label htmlFor="phoneNumber" className="text-sm">
Nomor Handphone
</label>
<Input
type="number"
id="phoneNumber"
placeholder=""
value={value}
onChange={onChange}
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
/>
</div>
)}
/>
{errors?.phoneNumber && (
<p className="text-red-400 text-sm mb-3">
{errors.phoneNumber?.message}
</p>
)}
</div>
<Button color="primary" type="submit" className="w-fit mt-4">
Simpan
</Button>
</form>
);
}

View File

@ -0,0 +1,204 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import DOMPurify from "dompurify";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { close, error, loading } from "@/config/swal";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { useParams, useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { editCustomStaticPage, getCustomStaticDetail } from "@/service/static-page-service";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
const formSchema = z.object({
slug: z.string().min(2, {
message: "Slug must be at least 2 characters.",
}),
title: z.string().min(2, {
message: "Title must be at least 2 characters.",
}),
description: z.string().min(2, {
message: "Main Keyword must be at least 2 characters.",
}),
htmlBody: z.string().min(2, {
message: "Main Keyword must be at least 2 characters.",
}),
});
export default function StaticPageBuilderEdit() {
const MySwal = withReactContent(Swal);
const router = useRouter();
const params = useParams();
const id = params.id;
const formOptions = {
resolver: zodResolver(formSchema),
};
type UserSettingSchema = z.infer<typeof formSchema>;
const {
control,
handleSubmit,
formState: { errors },
watch,
setValue,
getValues,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
initFetch();
}, [id]);
const initFetch = async () => {
const res = await getCustomStaticDetail(id ? String(id) : "");
const data = res?.data?.data;
console.log("res", data);
setValue("title", data?.title);
setValue("slug", data?.slug);
setValue("description", data?.description);
setValue("htmlBody", addPreCode(data?.htmlBody));
};
const addPreCode = (htmlString: string): string => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, "text/html");
const bodyContent = doc.body.innerHTML.trim();
return `<pre><code class="language-html">${bodyContent.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</code></pre>`;
};
const content = watch("htmlBody");
const generatedPage = useCallback(() => {
const sanitizedContent = DOMPurify.sanitize(content);
const textArea = document.createElement("textarea");
textArea.innerHTML = sanitizedContent;
return (
<Card className="rounded-md border p-4">
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
</Card>
);
}, [content]);
function createSlug(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const request = {
id: Number(id),
title: values.title,
slug: values.slug,
description: values.description,
htmlBody: values.htmlBody,
};
loading();
const res = await editCustomStaticPage(request);
if (res?.error) {
error(res.message);
return false;
}
close();
successSubmit("/admin/static-page");
};
function successSubmit(redirect: any) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
// const title = watch("title");
// useEffect(() => {
// if (getValues("title")) {
// setValue("slug", createSlug(getValues("title")));
// }
// }, [title]);
return (
<form className="flex flex-col gap-3 px-4" onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="title" className="text-sm font-medium">
Title
</Label>
<Input type="text" id="title" placeholder="Title" value={value ?? ""} onChange={onChange} />
</div>
)}
/>
{errors.title?.message && <p className="text-red-400 text-sm">{errors.title?.message}</p>}
<Controller
control={control}
name="slug"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-2">
<Label htmlFor="slug" className="text-sm font-medium">
Slug
</Label>
<Input type="text" id="slug" placeholder="Slug" value={value ?? ""} onChange={onChange} />
</div>
)}
/>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => <div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium">
Description
</Label>
<Textarea
id="description"
placeholder="Description"
value={value ?? ""}
onChange={onChange}
/>
</div>}
/>
{errors.description?.message && <p className="text-red-400 text-sm">{errors.description?.message}</p>}
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="flex flex-col gap-2">
Editor
<Controller control={control} name="htmlBody" render={({ field: { onChange, value } }) => <CustomEditor onChange={onChange} initialData={value} />} />
</div>
<div className="px-4 flex flex-col gap-2">
Preview
{generatedPage()}
</div>
</div>
<div className="flex justify-end w-full">
<Button type="submit" color="primary" className="w-fit">
Save
</Button>
</div>
</form>
);
}

2734
components/icons.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,214 @@
import * as React from "react";
import { IconSvgProps } from "@/types/globals";
export const DashboardUserIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 3c2.21 0 4 1.79 4 4s-1.79 4-4 4s-4-1.79-4-4s1.79-4 4-4m4 10.54c0 1.06-.28 3.53-2.19 6.29L13 15l.94-1.88c-.62-.07-1.27-.12-1.94-.12s-1.32.05-1.94.12L11 15l-.81 4.83C8.28 17.07 8 14.6 8 13.54c-2.39.7-4 1.96-4 3.46v4h16v-4c0-1.5-1.6-2.76-4-3.46"
/>
</svg>
);
export const DashboardBriefcaseIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 48 48"
width={size || width}
{...props}
>
<path fill="#f5bc00" d="M44,41H4V10h40V41z" />
<polygon fill="#eb7900" points="44,26 24,26 4,26 4,10 44,10" />
<path fill="#eb7900" d="M17,26h-6v3h6V26z" />
<path fill="#eb7900" d="M37,26h-6v3h6V26z" />
<rect width="14" height="3" x="17" y="7" fill="#f5bc00" />
<path fill="#eb0000" d="M17,23h-6v3h6V23z" />
<path fill="#eb0000" d="M37,23h-6v3h6V23z" />
</svg>
);
export const DashboardMailboxIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
width={size || width}
{...props}
viewBox="0 0 48 48"
>
<path fill="#3dd9eb" d="M43,36H13V11h22c4.418,0,8,3.582,8,8V36z" />
<path
fill="#7debf5"
d="M21,36H5V19c0-4.418,3.582-8,8-8l0,0c4.418,0,8,3.582,8,8V36z"
/>
<path fill="#6c19ff" d="M21,36h5v8h-5V36z" />
<polygon fill="#eb0000" points="27,16 27,20 35,20 35,24 39,24 39,16" />
<rect width="8" height="3" x="9" y="20" fill="#3dd9eb" />
</svg>
);
export const DashboardShareIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
{...props}
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="M503.691 189.836L327.687 37.851C312.281 24.546 288 35.347 288 56.015v80.053C127.371 137.907 0 170.1 0 322.326c0 61.441 39.581 122.309 83.333 154.132c13.653 9.931 33.111-2.533 28.077-18.631C66.066 312.814 132.917 274.316 288 272.085V360c0 20.7 24.3 31.453 39.687 18.164l176.004-152c11.071-9.562 11.086-26.753 0-36.328"
/>
</svg>
);
export const DashboardSpeecIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
{...props}
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M7 0a2 2 0 0 0-2 2h9a2 2 0 0 1 2 2v12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"
/>
<path
fill="currentColor"
d="M13 20a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2zM9 5h4v5H9zM4 5h4v1H4zm0 2h4v1H4zm0 2h4v1H4zm0 2h9v1H4zm0 2h9v1H4zm0 2h9v1H4z"
/>
</svg>
);
export const DashboardConnectIcon = ({
size,
height = 48,
width = 48,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 22V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6zm7.075-7.75L12 12.475l2.925 1.775l-.775-3.325l2.6-2.25l-3.425-.275L12 5.25L10.675 8.4l-3.425.275l2.6 2.25z"
/>
</svg>
);
export const DashboardTopLeftPointIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M18 18L6 6m0 0h9M6 6v9"
/>
</svg>
);
export const DashboardRightDownPointIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M5.47 5.47a.75.75 0 0 1 1.06 0l10.72 10.72V9a.75.75 0 0 1 1.5 0v9a.75.75 0 0 1-.75.75H9a.75.75 0 0 1 0-1.5h7.19L5.47 6.53a.75.75 0 0 1 0-1.06"
clipRule="evenodd"
/>
</svg>
);
export const DashboardCommentIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
{...props}
viewBox="0 0 48 48"
>
<defs>
<mask id="ipSComment0">
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
>
<path fill="#fff" stroke="#fff" d="M44 6H4v30h9v5l10-5h21z" />
<path stroke="#000" d="M14 19.5v3m10-3v3m10-3v3" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSComment0)" />
</svg>
);

View File

@ -0,0 +1,196 @@
import * as React from "react";
import { IconSvgProps } from "@/types/globals";
export const PdfIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 15 15"
{...props}
>
<path
fill="currentColor"
d="M3.5 8H3V7h.5a.5.5 0 0 1 0 1M7 10V7h.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5z"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM3.5 6H2v5h1V9h.5a1.5 1.5 0 1 0 0-3m4 0H6v5h1.5A1.5 1.5 0 0 0 9 9.5v-2A1.5 1.5 0 0 0 7.5 6m2.5 5V6h3v1h-2v1h1v1h-1v2z"
clipRule="evenodd"
/>
</svg>
);
export const CsvIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 15 15"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h3v1H3v3h2v1H2zm7 0H6v3h2v1H6v1h3V8H7V7h2zm2 0h-1v3.707l1.5 1.5l1.5-1.5V6h-1v3.293l-.5.5l-.5-.5z"
clipRule="evenodd"
/>
</svg>
);
export const ExcelIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 15 15"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M3.793 7.5L2.146 5.854l.708-.708L4.5 6.793l1.646-1.647l.708.708L5.207 7.5l1.647 1.646l-.708.708L4.5 8.207L2.854 9.854l-.708-.708z"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
clipRule="evenodd"
/>
</svg>
);
export const WordIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 15 15"
>
<path
fill="currentColor"
d="m2.015 5.621l1 4a.5.5 0 0 0 .901.156l.584-.876l.584.876a.5.5 0 0 0 .901-.156l1-4l-.97-.242l-.726 2.903l-.373-.56a.5.5 0 0 0-.832 0l-.373.56l-.726-2.903z"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
clipRule="evenodd"
/>
</svg>
);
export const PptIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 15 15"
>
<path
fill="currentColor"
d="M3 8h.5a.5.5 0 0 0 0-1H3zm4 0h.5a.5.5 0 0 0 0-1H7z"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h1.5a1.5 1.5 0 1 1 0 3H3v2H2zm4 0h1.5a1.5 1.5 0 1 1 0 3H7v2H6zm5 5h1V7h1V6h-3v1h1z"
clipRule="evenodd"
/>
</svg>
);
export const FileIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 15 15"
>
<path
fill="currentColor"
d="m10.5.5l.354-.354L10.707 0H10.5zm3 3h.5v-.207l-.146-.147zm-1 10.5h-10v1h10zM2 13.5v-12H1v12zM2.5 1h8V0h-8zM13 3.5v10h1v-10zM10.146.854l3 3l.708-.708l-3-3zM2.5 14a.5.5 0 0 1-.5-.5H1A1.5 1.5 0 0 0 2.5 15zm10 1a1.5 1.5 0 0 0 1.5-1.5h-1a.5.5 0 0 1-.5.5zM2 1.5a.5.5 0 0 1 .5-.5V0A1.5 1.5 0 0 0 1 1.5z"
/>
</svg>
);
export const UserProfileIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 16 16"
>
<path
fill="currentColor"
d="M11 7c0 1.66-1.34 3-3 3S5 8.66 5 7s1.34-3 3-3s3 1.34 3 3"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M16 8c0 4.42-3.58 8-8 8s-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8M4 13.75C4.16 13.484 5.71 11 7.99 11c2.27 0 3.83 2.49 3.99 2.75A6.98 6.98 0 0 0 14.99 8c0-3.87-3.13-7-7-7s-7 3.13-7 7c0 2.38 1.19 4.49 3.01 5.75"
clipRule="evenodd"
/>
</svg>
);
export const SettingsIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"
/>
</svg>
);

View File

@ -0,0 +1,487 @@
import * as React from "react";
import { IconSvgProps } from "@/types/globals";
export const MenuBurgerIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M3 6h18M3 12h18M3 18h18"
/>
</svg>
);
export const DashboardIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
fill="currentColor"
d="M13 9V3h8v6zM3 13V3h8v10zm10 8V11h8v10zM3 21v-6h8v6z"
/>
</svg>
);
export const HomeIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 12.204c0-2.289 0-3.433.52-4.381c.518-.949 1.467-1.537 3.364-2.715l2-1.241C9.889 2.622 10.892 2 12 2c1.108 0 2.11.622 4.116 1.867l2 1.241c1.897 1.178 2.846 1.766 3.365 2.715c.519.948.519 2.092.519 4.38v1.522c0 3.9 0 5.851-1.172 7.063C19.657 22 17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.212C2 19.576 2 17.626 2 13.725z" />
<path strokeLinecap="round" d="M12 15v3" />
</g>
</svg>
);
export const Submenu1Icon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 48 48"
width={size || width}
{...props}
>
<defs>
<mask id="ipTData0">
<g
fill="none"
stroke="#fff"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
>
<path d="M44 11v27c0 3.314-8.954 6-20 6S4 41.314 4 38V11" />
<path d="M44 29c0 3.314-8.954 6-20 6S4 32.314 4 29m40-9c0 3.314-8.954 6-20 6S4 23.314 4 20" />
<ellipse cx="24" cy="10" fill="#555" rx="20" ry="6" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTData0)" />
</svg>
);
export const Submenu2Icon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<path
fill="currentColor"
d="M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8"
/>
</svg>
);
export const InfoCircleIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 1024 1024"
width={size || width}
{...props}
>
<path
fill="currentColor"
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448s448-200.6 448-448S759.4 64 512 64m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372s372 166.6 372 372s-166.6 372-372 372"
/>
<path
fill="currentColor"
d="M464 336a48 48 0 1 0 96 0a48 48 0 1 0-96 0m72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8"
/>
</svg>
);
export const MinusCircleIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 16 16"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
<path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8" />
</g>
</svg>
);
export const TableIcon = ({
size,
height = 24,
width = 22,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 24 22"
width={size || width}
strokeWidth="1.5"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"
/>
</svg>
);
export const ArticleIcon = ({
size,
height = 20,
width = 20,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
viewBox="0 0 20 20"
{...props}
>
<path
fill="currentColor"
d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z"
/>
</svg>
);
export const MagazineIcon = ({
size,
height = 20,
width = 20,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size || height}
width={size || width}
viewBox="0 0 128 128"
{...props}
>
<path
fill="#bdbdbd"
d="M-125.7 124.54V11.79c29.36 1.85 58.81 1.91 88.18.19c1.77-.1 3.21 1.08 3.21 2.66v107.04c0 1.58-1.44 2.94-3.21 3.05a727 727 0 0 1-88.18-.19"
/>
<path
fill="#e0e0e0"
d="M-125.7 124.54V11.79c27.11-5.31 54.34-8.57 81.45-9.76c1.64-.07 2.96 1.15 2.96 2.73V111.8c0 1.58-1.33 2.9-2.96 2.98c-27.11 1.19-54.34 4.45-81.45 9.76"
/>
<g fill="#757575">
<path d="M-92.84 42.86c-7.46 1-14.91 2.16-22.36 3.47v-3.22c7.45-1.31 14.9-2.47 22.36-3.47zm-12.76-15.72c-3.2.51-6.4 1.04-9.6 1.6v-8.47c3.2-.56 6.4-1.1 9.6-1.6zm12.17-1.78c-3.2.43-6.4.9-9.6 1.39v-8.47c3.2-.49 6.4-.95 9.6-1.39zm12.17-1.52q-4.8.54-9.6 1.17v-8.47q4.8-.63 9.6-1.17zm12.17-1.23c-3.2.29-6.4.61-9.6.95v-8.47c3.2-.35 6.4-.66 9.6-.95zm17.21 5.12a548 548 0 0 0-63.31 7.42v-.81c21.09-3.72 42.23-6.19 63.31-7.42zm-32.67 14.08c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
<path d="M-75.06 40.77c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.11l-3.66.3v-3.22l3.66-.3z" />
<path d="M-65.38 39.87c-2.47.21-4.95.43-7.42.67v-3.22c2.47-.24 4.95-.46 7.42-.67zm10.32-.76c-2.02.13-4.05.27-6.07.42v-3.22c2.02-.15 4.05-.29 6.07-.42zm-49.89 14.57c-3.42.54-6.83 1.11-10.25 1.71v-3.22c3.41-.6 6.83-1.17 10.25-1.71zm9.51-1.4c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16z" />
<path d="M-84.55 50.87c-3.95.47-7.89.98-11.84 1.54v-3.22c3.95-.56 7.89-1.07 11.84-1.54z" />
<path d="M-75.06 49.82c-3.6.37-7.21.77-10.81 1.2V47.8c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3V45.8l3.66-.3z" />
<path d="M-61.13 48.59c-3.89.29-7.78.63-11.67 1.01v-3.22c3.89-.38 7.78-.71 11.67-1.01z" />
<path d="M-51.89 47.97c-3.08.18-6.16.39-9.25.62v-3.22c3.08-.23 6.17-.44 9.25-.62zm-49.5 14.22q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
<path d="M-95.44 61.33c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16zm10.89-1.4c-2.76.33-5.53.68-8.29 1.05v-3.22c2.76-.37 5.53-.72 8.29-1.05z" />
<path d="M-78.26 59.21c-2.54.27-5.07.56-7.61.87v-3.22c2.54-.31 5.07-.6 7.61-.87zm26.37 24.99q-12.075.705-24.18 1.95V55.76q12.09-1.245 24.18-1.95zm-38.55-14.48c-8.25 1.07-16.51 2.34-24.75 3.79v-3.22c8.24-1.45 16.5-2.72 24.75-3.79z" />
<path d="M-95.44 70.39c-1.31.18-2.63.37-3.94.56v-3.22c1.31-.19 2.63-.38 3.94-.56zm10.89-1.41c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
<path d="M-78.32 68.28c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
<path d="M-98.16 79.83c-1.72.25-3.44.51-5.17.77v-3.22c1.72-.27 3.44-.52 5.17-.77zm13.61-1.79q-5.445.645-10.89 1.41v-3.22c3.63-.51 7.26-.97 10.89-1.41z" />
<path d="M-80.46 77.57c-1.8.2-3.6.41-5.41.63v-3.22c1.8-.22 3.6-.43 5.41-.63zm-16.95 11.21c-5.93.85-11.86 1.79-17.79 2.84V88.4c5.92-1.04 11.85-1.99 17.79-2.84z" />
<path d="M-92.54 88.1c-2.28.31-4.56.62-6.84.96v-3.22q3.42-.495 6.84-.96zm7.99-1.01c-1.75.21-3.5.43-5.25.65v-3.22c1.75-.23 3.5-.44 5.25-.65z" />
<path d="M-78.32 86.39c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25zm14.22-1.95q-6.105.75-12.21 1.65V94.9q6.105-.9 12.21-1.65z" />
<path d="M-84.55 96.15c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
<path d="M-75.06 95.1c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3v-3.22l3.66-.3zm-5.64.47c-1.46.13-2.93.27-4.39.41v-3.22c1.46-.14 2.93-.28 4.39-.41z" />
<path d="M-51.89 93.25c-6 .35-12.01.8-18.01 1.35v-3.22c6.01-.55 12.01-1 18.01-1.35zm-43.56 13.36c-6.59.92-13.17 1.96-19.75 3.11v-3.22c6.58-1.16 13.16-2.2 19.75-3.11zm22.09-2.62c-3.48.34-6.96.72-10.44 1.13v-3.22c3.48-.41 6.96-.78 10.44-1.13zm-13.53 1.5c-2.18.27-4.36.55-6.55.85v-3.22c2.18-.3 4.36-.58 6.55-.85z" />
</g>
<path
fill="#eee"
d="M15.71 280.41V170.86h76.08a2.77 2.77 0 0 1 2.77 2.77v104.01a2.77 2.77 0 0 1-2.77 2.77z"
/>
<g fill="#757575">
<path d="M25.53 203.19h20.88v3.13H25.53zm0-22.19h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm-34.08 13.66h59.12v.79H25.53zm22.44 8.53h6.18v3.13h-6.18z" />
<path d="M52.92 203.19h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
<path d="M65.11 203.19h6.93v3.13h-6.93zm10.9 0h5.67v3.13h-5.67zm-50.48 8.8h9.57v3.13h-9.57zm11.08 0h7.37v3.13h-7.37z" />
<path d="M43.1 211.99h11.05v3.13H43.1z" />
<path d="M52.92 211.99h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
<path d="M65.11 211.99H76v3.13H65.11zm10.9 0h8.64v3.13h-8.64zm-50.48 8.8h12.89v3.13H25.53z" />
<path d="M36.61 220.79h7.37v3.13h-7.37zm9.8 0h7.74v3.13h-7.74z" />
<path d="M52.92 220.79h7.1v3.13h-7.1zm9.15 0h22.58v29.53H62.07zm-36.54 8.8h23.11v3.13H25.53z" />
<path d="M40.3 229.59h3.68v3.13H40.3zm7.67 0h6.18v3.13h-6.18z" />
<path d="M52.92 229.59h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53z" />
<path d="M36.61 238.39h4.82v3.13h-4.82zm7.37 0h10.17v3.13H43.98z" />
<path d="M52.92 238.39h5.04v3.13h-5.04zm-27.39 8.79h16.61v3.13H25.53z" />
<path d="M40.3 247.18h6.38v3.13H40.3zm8.95 0h4.9v3.13h-4.9z" />
<path d="M52.92 247.18h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53zm14.77 0h11.39v3.13H40.3z" />
<path d="M47.97 255.98h6.18v3.13h-6.18z" />
<path d="M52.92 255.98h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42zm-5.95 0h4.1v3.13h-4.1z" />
<path d="M67.82 255.98h16.82v3.13H67.82zm-42.29 8.8h18.44v3.13H25.53zm29.32 0h9.74v3.13h-9.74zm-9 0h6.11v3.13h-6.11z" />
</g>
<path
fill="#bdbdbd"
d="M16.62 124.27V14.04c30.52 2.2 61.18 2.27 91.71.21c1.68-.11 3.05 1.04 3.05 2.58v104.65c0 1.54-1.36 2.89-3.05 3a659 659 0 0 1-91.71-.21"
/>
<path
fill="#e0e0e0"
d="M16.62 124.25V14.02C44.36 7.91 72.21 3.9 99.95 2.03c1.53-.1 2.77 1.07 2.77 2.61v104.65c0 1.54-1.24 2.87-2.77 2.97c-27.74 1.87-55.59 5.88-83.33 11.99"
/>
<path
fill="none"
stroke="#616161"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="5"
d="M28.75 49.34c20.6-4.08 41.25-7 61.84-8.74M28.75 63.23a565 565 0 0 1 26.45-4.6M28.75 77.11a565 565 0 0 1 26.45-4.6m-26.45 33.06c20.6-4.08 41.25-7 61.84-8.74M28.75 91a565 565 0 0 1 26.45-4.6"
/>
<path
fill="#616161"
d="M64.86 87.55a560 560 0 0 1 24.67-2.69c1.49-.13 2.69-1.44 2.69-2.94V54.54c0-1.5-1.21-2.61-2.69-2.48c-8.22.71-16.44 1.61-24.67 2.69c-1.49.2-2.7 1.58-2.7 3.07V85.2c.01 1.5 1.21 2.55 2.7 2.35m-34.4-52.14c2.03-.4 4.05-.78 6.08-1.15c1.49-.27 2.69-1.7 2.69-3.2v-7.02c0-1.5-1.21-2.49-2.69-2.22c-2.03.37-4.05.76-6.08 1.15c-1.49.29-2.69 1.75-2.69 3.24v7.02c-.01 1.5 1.2 2.47 2.69 2.18m15.96-2.88c2.03-.34 4.05-.66 6.08-.97c1.49-.23 2.7-1.62 2.7-3.12v-7.02c0-1.5-1.21-2.53-2.7-2.3c-2.03.31-4.06.64-6.08.97c-1.49.25-2.69 1.67-2.69 3.16v7.02c0 1.5 1.2 2.51 2.69 2.26m15.97-2.41c2.03-.28 4.06-.54 6.08-.8c1.49-.19 2.7-1.54 2.7-3.04v-7.02c0-1.5-1.21-2.57-2.7-2.38c-2.03.25-4.06.52-6.08.8c-1.49.2-2.7 1.59-2.7 3.08v7.02c.01 1.5 1.22 2.54 2.7 2.34"
/>
<path
fill="#e0e0e0"
d="M374.07 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
/>
<path
fill="none"
stroke="#616161"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="5"
d="M387.48 86.21h68.34m-68.34 15.26h29.23m-29.23 15.26h29.23M387.48 148h68.34m-68.34-16.01h29.23"
/>
<path
fill="#616161"
d="M427.38 134.75h27.26c1.64 0 2.98-1.33 2.98-2.98v-30.08c0-1.64-1.33-2.98-2.98-2.98h-27.26c-1.64 0-2.98 1.33-2.98 2.98v30.08a2.987 2.987 0 0 0 2.98 2.98m-38.01-63.47h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98m17.64 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71a2.987 2.987 0 0 0 2.98 2.98m17.65 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98"
/>
<path
fill="#bdbdbd"
d="M479.86 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
/>
</svg>
);
export const StaticPageIcon = ({
size,
height = 20,
width = 20,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 2048 2048"
{...props}
>
<path
fill="currentColor"
d="M1755 512h-475V37zm37 128v1408H128V0h1024v640z"
/>
</svg>
);
export const MasterUsersIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 640 512"
{...props}
>
<path
fill="currentColor"
d="M144 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160m368 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160M0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96H21.3C9.6 320 0 310.4 0 298.7M405.3 320h-.7c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7c58.9 0 106.7 47.8 106.7 106.7c0 11.8-9.6 21.3-21.3 21.3H405.4zM224 224a96 96 0 1 1 192 0a96 96 0 1 1-192 0m-96 261.3c0-73.6 59.7-133.3 133.3-133.3h117.3c73.7 0 133.4 59.7 133.4 133.3c0 14.7-11.9 26.7-26.7 26.7H154.6c-14.7 0-26.7-11.9-26.7-26.7z"
/>
</svg>
);
export const MasterRoleIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M15 21h-2a2 2 0 0 1 0-4h2v-2h-2a4 4 0 0 0 0 8h2Zm8-2a4 4 0 0 1-4 4h-2v-2h2a2 2 0 0 0 0-4h-2v-2h2a4 4 0 0 1 4 4"
/>
<path
fill="currentColor"
d="M14 18h4v2h-4zm-7 1a6 6 0 0 1 .09-1H3v-1.4c0-2 4-3.1 6-3.1a8.6 8.6 0 0 1 1.35.125A5.95 5.95 0 0 1 13 13h5V4a2.006 2.006 0 0 0-2-2h-4.18a2.988 2.988 0 0 0-5.64 0H2a2.006 2.006 0 0 0-2 2v14a2.006 2.006 0 0 0 2 2h5.09A6 6 0 0 1 7 19M9 2a1 1 0 1 1-1 1a1.003 1.003 0 0 1 1-1m0 4a3 3 0 1 1-3 3a2.996 2.996 0 0 1 3-3"
/>
</svg>
);
export const MasterUserLevelIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 640 512"
{...props}
>
<path
fill="currentColor"
d="M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32S80 82.1 80 144s50.1 112 112 112m76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2M480 256c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96s43 96 96 96m48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4c24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48c0-61.9-50.1-112-112-112"
/>
</svg>
);
export const MasterCategoryIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 32 32"
{...props}
>
<path
fill="currentColor"
d="M14 25h14v2H14zm-6.83 1l-2.58 2.58L6 30l4-4l-4-4l-1.42 1.41zM14 15h14v2H14zm-6.83 1l-2.58 2.58L6 20l4-4l-4-4l-1.42 1.41zM14 5h14v2H14zM7.17 6L4.59 8.58L6 10l4-4l-4-4l-1.42 1.41z"
/>
</svg>
);
export const AddvertiseIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 8q-1.65 0-2.825 1.175T8 12q0 1.125.563 2.075t1.562 1.475q.4.2.563.587t-.013.788q-.175.35-.525.525t-.7 0q-1.575-.75-2.512-2.225T6 12q0-2.5 1.75-4.25T12 6q1.775 0 3.263.938T17.475 9.5q.15.35-.012.7t-.513.5q-.4.175-.8 0t-.6-.575q-.525-1-1.475-1.562T12 8m0-4Q8.65 4 6.325 6.325T4 12q0 3.15 2.075 5.4t5.2 2.55q.425.05.737.375t.288.75t-.313.7t-.712.25q-1.95-.125-3.638-.975t-2.95-2.213t-1.975-3.125T2 12q0-2.075.788-3.9t2.137-3.175T8.1 2.788T12 2q3.925 0 6.838 2.675t3.187 6.6q.05.4-.237.688t-.713.312t-.762-.275t-.388-.725q-.375-3-2.612-5.137T12 4m7.55 17.5l-3.3-3.275l-.75 2.275q-.125.35-.475.338t-.475-.363L12.275 12.9q-.1-.275.125-.5t.5-.125l7.575 2.275q.35.125.363.475t-.338.475l-2.275.75l3.3 3.3q.425.425.425.975t-.425.975t-.987.425t-.988-.425"
/>
</svg>
);
export const SuggestionsIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M17.175 14H7.5q-1.875 0-3.187-1.312T3 9.5t1.313-3.187T7.5 5q.425 0 .713.288T8.5 6t-.288.713T7.5 7q-1.05 0-1.775.725T5 9.5t.725 1.775T7.5 12h9.675L14.3 9.1q-.275-.275-.288-.687T14.3 7.7q.275-.275.7-.275t.7.275l4.6 4.6q.3.3.3.7t-.3.7l-4.6 4.6q-.3.3-.7.288t-.7-.313q-.275-.3-.288-.7t.288-.7z"
/>
</svg>
);
export const CommentIcon = ({
size,
height = 24,
width = 24,
fill = "currentColor",
...props
}: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M6.5 13.5h11v-1h-11zm0-3h11v-1h-11zm0-3h11v-1h-11zM4.616 17q-.691 0-1.153-.462T3 15.385V4.615q0-.69.463-1.153T4.615 3h14.77q.69 0 1.152.462T21 4.615v15.462L17.923 17z"
/>
</svg>
);

View File

@ -0,0 +1,46 @@
import Image from "next/image";
import { Link, Mail, MailIcon, Share2 } from "lucide-react";
export default function Author() {
return (
<section className="w-full bg-white py-6">
<p className="mx-10 text-2xl mb-4">AUTHOR</p>
<div className=" border border-black p-6 flex items-center gap-6 max-w-[1200px] mx-auto">
{/* Foto Profil */}
<div className="w-20 h-20 relative ">
<Image
src="/author.png" // Ganti dengan path gambar kamu
alt="Author"
fill
className="rounded-full object-cover"
/>
</div>
{/* Info Author */}
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-800">Admin</h3>
<div className="mt-2 flex items-center gap-4 flex-wrap">
{/* Button lihat semua post */}
<button className="text-sm font-medium text-white hover:underline bg-[#655997] py-1 px-5 rounded-xl">
Lihat Semua Pos
</button>
<div className="bg-[#655997] rounded-full p-1">
<MailIcon
size={18}
className="text-white hover:text-black cursor-pointer "
></MailIcon>
</div>
<div className="bg-[#655997] rounded-full p-1">
<Link
size={18}
className="text-white hover:text-black cursor-pointer "
></Link>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,449 @@
"use client";
import { getListArticle } from "@/service/article";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getAdvertise } from "@/service/advertisement";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
type Advertise = {
id: number;
title: string;
description: string;
placement: string;
contentFileUrl: string;
redirectLink: string;
};
export default function CitizenNews() {
const [activeTab, setActiveTab] = useState("comments");
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
const [bannerAd, setBannerAd] = useState<Advertise | null>(null);
useEffect(() => {
initStateAdver();
}, []);
async function initStateAdver() {
const req = {
limit: 100,
page: 1,
sort: "desc",
sortBy: "created_at",
isPublish: true,
};
try {
const res = await getAdvertise(req);
const data: Advertise[] = res?.data?.data || [1];
// filter iklan dengan placement = "banner"
const banner = data.find((ad) => ad.placement === "jumbotron");
if (banner) {
setBannerAd(banner);
}
} catch (err) {
console.error("Error fetching advertisement:", err);
}
}
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories, activeTab]);
async function initState() {
let sortBy = "created_at";
if (activeTab === "comments") sortBy = "comment_count";
if (activeTab === "trending") sortBy = "view_count";
// loading();
const req = {
limit: showData,
page: 1,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
const citizenArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
// Pagination manually (front-end)
const itemsPerPage = 2;
const calculatedTotalPage = Math.ceil(citizenArticles.length / itemsPerPage);
const paginatedArticles = citizenArticles.slice(
(page - 1) * itemsPerPage,
page * itemsPerPage
);
function truncateText(text: string, wordLimit: number) {
const words = text.split(" ");
if (words.length <= wordLimit) return text;
return words.slice(0, wordLimit).join(" ") + "...";
}
return (
<div className="bg-white grid grid-cols-1 lg:grid-cols-3 gap-6 py-10 px-8">
{/* Left Content */}
<div className="lg:col-span-2 space-y-10">
{paginatedArticles.map((item) => (
<div key={item.id}>
<Link
className="flex flex-col md:flex-row gap-6"
href={`/detail/${item?.id}`}
>
{/* Image + Category */}
<div className="relative w-full md:w-1/2 h-64">
<Image
src={item.thumbnailUrl || "/placeholder.png"}
alt={item.title}
fill
className="object-cover rounded"
/>
<span className="absolute top-3 left-3 bg-yellow-400 text-black px-3 py-1 text-xs font-bold">
{item.categories[0]?.title || "Pembangunan"}
</span>
</div>
{/* Content */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[#16324F] hover:text-blue-600 cursor-pointer">
{item.title}
</h2>
<div className="text-sm text-gray-600 mt-2">
BY{" "}
<span className="text-blue-600 font-semibold">
{item?.customCreatorName || item.createdByName}
</span>{" "}
{new Date(item.createdAt).toLocaleDateString("id-ID")}
</div>
<p className="mt-3 text-gray-700">
{truncateText(item.description, 20)}
</p>
<button className="mt-4 px-4 py-2 border border-gray-400 text-gray-700 hover:bg-black hover:text-white transition">
READ MORE
</button>
</div>
</Link>
</div>
))}
{/* Pagination */}
<div className="flex items-center justify-center gap-2 mt-6">
{/* Previous Button */}
<button
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
disabled={page === 1}
className="px-3 py-1 border"
>
&lt;
</button>
{/* Page Numbers */}
{Array.from({ length: calculatedTotalPage }, (_, i) => i + 1)
.filter((p) => {
// Always show first, last, current, and pages around current
return (
p === 1 ||
p === calculatedTotalPage ||
(p >= page - 1 && p <= page + 1)
);
})
.map((p, idx, arr) => {
const prev = arr[idx - 1];
const showEllipsis = prev && p - prev > 1;
return (
<span key={p} className="flex items-center">
{showEllipsis && <span className="px-2">...</span>}
<button
onClick={() => setPage(p)}
className={`px-3 py-1 ${
page === p ? "bg-blue-600 text-white" : "border"
}`}
>
{p}
</button>
</span>
);
})}
{/* Next Button */}
<button
onClick={() =>
setPage((prev) => Math.min(prev + 1, calculatedTotalPage))
}
disabled={page === calculatedTotalPage}
className="px-3 py-1 border"
>
&gt;
</button>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Advertisement */}
<div className="w-full h-[400px] relative">
{bannerAd ? (
<a
href={bannerAd.redirectLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full"
>
<div className="relative w-full h-[350px] flex justify-center">
<Image
src={bannerAd.contentFileUrl}
alt={bannerAd.title || "Iklan Banner"}
width={1200} // ukuran dasar untuk responsive
height={350}
className="object-cover w-full h-full"
/>
</div>
</a>
) : (
<Image
src="/kolom.png"
alt="Berita Utama"
width={1200}
height={188}
className="object-contain w-full h-[188px]"
/>
)}
</div>
{/* Connect with us */}
<div>
<h3 className="text-lg font-semibold mb-2">Connect with us</h3>
<div className="h-1 w-20 bg-blue-600 mb-4"></div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-blue-500 text-white text-center p-3">
<p className="text-xl font-bold">138</p>
<p className="text-sm">Followers</p>
</div>
<div className="bg-red-600 text-white text-center p-3">
<p className="text-xl font-bold">205k</p>
<p className="text-sm">Subscribers</p>
</div>
<div className="bg-yellow-400 text-black text-center p-3">
<p className="text-xl font-bold">23.9k</p>
<p className="text-sm">Followers</p>
</div>
</div>
</div>
{/* Tabs */}
<div>
<div className="flex gap-4 border-b">
{["trending", "comments", "latest"].map((tab) => (
<button
key={tab}
className={`pb-2 capitalize ${
activeTab === tab
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600"
}`}
onClick={() => {
setPage(1); // reset page setiap ganti tab
setActiveTab(tab);
}}
>
{tab}
</button>
))}
</div>
<div className="mt-4 space-y-4">
{articles.slice(0, 5).map((item) => (
<div key={item.id} className="flex gap-3 items-center">
<Link
className="flex gap-3 items-center"
href={`/detail/${item?.id}`}
>
<Image
src={item.thumbnailUrl || "/no-image.jpg"}
alt={item.title}
width={80}
height={60}
className="object-cover rounded"
/>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-xs text-gray-500">
{new Date(item.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
</div>
))}
</div>
<div className="">
<h2 className="text-sm border-b-2 border-gray-300 font-bold mb-4">
Recommended
</h2>
<div className=" w-full">
<div className="relative w-full aspect-video mb-5">
<Link href={`/detail/${articles[0]?.id}`}>
<Image
src={
articles[0]?.thumbnailUrl ||
articles[0]?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"articles[0]?.title"}
fill
sizes="(max-width: 1024px) 100vw, 33vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-black/30" />
<div className="absolute bottom-0.5 left-2 text-white">
<h3 className=" font-semibold text-base mb-1">
{articles[0]?.title}
</h3>
<p className=" text-xs mb-2 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
<div className="space-y-5">
{articles?.slice(1, 4).map((article, index) => (
<div key={index}>
<Link
className="flex gap-3"
href={`/detail/${article?.id}`}
>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={
article?.thumbnailUrl ||
article?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"article?.title"}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3">
{article?.title}
</h4>
<p className="text-xs text-gray-500 flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { getListArticle } from "@/service/article";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
export default function HeaderCitizen() {
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories]);
async function initState() {
// loading();
const req = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
const citizenArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
const mainArticle = citizenArticles[0];
const otherArticles = citizenArticles.slice(1, 3);
return (
<section className="max-w-7xl mx-auto bg-white">
<div className="flex flex-col items-start bg-[#F2F4F3] w-full overflow-hidden py-6 px-8 gap-3">
<p className="text-gray-400 text-sm">
<Link href="/" className="hover:underline">
Home
</Link>{" "}
{">"} <span className="text-black">{categoryLabel}</span>
</p>
<p className="text-3xl font-bold ">Berita Warga</p>
</div>
<div className="pb-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 m-8">
{mainArticle && (
<div className="md:col-span-2 relative">
<Link href={`/detail/${mainArticle.id}`}>
<Image
src={mainArticle.files?.[0]?.fileUrl || "/default-image.jpg"}
alt={mainArticle.title}
width={800}
height={500}
className="w-full h-full max-h-[460px] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/50 to-transparent p-6 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block mb-2 uppercase w-[130px]">
{mainArticle.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h2 className="text-sm md:text-xl lg:text-2xl font-bold text-white leading-snug mb-2 w-full md:w-9/12">
{mainArticle.title}
</h2>
<p className="text-white text-xs">
{mainArticle?.customCreatorName ||
mainArticle.createdByName}{" "}
-{" "}
{new Date(mainArticle.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
)}
<div className="grid grid-rows-2 gap-2">
{otherArticles.map((article, index) => (
<div key={index} className="relative">
<Link href={`/detail/${article.id}`}>
<Image
src={
article.thumbnailUrl ||
article.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={article.title}
width={400}
height={240}
className="w-full h-56 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-4 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block uppercase w-[130px]">
{article.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h3 className="text-sm font-semibold text-white leading-snug mb-1">
{article.title}
</h3>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,249 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { getListArticle } from "@/service/article";
import Link from "next/link";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: { title: string }[];
files: { fileUrl: string; file_alt: string }[];
};
export default function Development() {
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
useEffect(() => {
initState();
}, [page]);
async function initState() {
const req = {
limit: "10",
page,
search: "",
categorySlug: "",
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} catch (err) {
console.error("Error fetching articles:", err);
}
}
// Format tanggal ke gaya lokal
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
// Mapping struktur seperti dummy sebelumnya
const leftMain = articles[0];
const leftList = articles.slice(1, 4);
const centerMain = articles[4];
const centerList = articles.slice(5, 8);
const rightMain = articles[8];
const rightList = articles.slice(9, 12);
return (
<section className="max-w-7xl mx-auto px-4">
<h2 className="text-lg font-bold text-white bg-red-600 inline-block px-4 py-2 border-b-2">
PEMBANGUNAN
</h2>
<h2 className="border-b-2 mb-4"></h2>
{articles.length === 0 ? (
<p className="text-center text-gray-500 py-10">Memuat berita...</p>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* === LEFT COLUMN === */}
{leftMain && (
<div className="w-full">
<Link href={`/detail/${leftMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={leftMain.thumbnailUrl || "/placeholder.jpg"}
alt={leftMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{leftMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{leftMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {leftMain.customCreatorName || leftMain.createdByName} ·{" "}
{formatDate(leftMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{leftMain.description}
</p>
</Link>
<div className="space-y-8">
{leftList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
{/* === CENTER COLUMN === */}
{centerMain && (
<div className="w-full">
<Link href={`/detail/${centerMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={centerMain.thumbnailUrl || "/placeholder.jpg"}
alt={centerMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{centerMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{centerMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {centerMain.customCreatorName || centerMain.createdByName}{" "}
· {formatDate(centerMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{centerMain.description}
</p>
</Link>
<div className="space-y-8">
{centerList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
{/* === RIGHT COLUMN === */}
{rightMain && (
<div className="w-full">
<Link href={`/detail/${rightMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={rightMain.thumbnailUrl || "/placeholder.jpg"}
alt={rightMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{rightMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{rightMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {rightMain.customCreatorName || rightMain.createdByName} ·{" "}
{formatDate(rightMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{rightMain.description}
</p>
</Link>
<div className="space-y-8">
{rightList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="relative my-8 h-[188px] overflow-hidden flex items-center mx-auto border">
<Image
src="/image-kolom.png"
alt="Berita Utama"
fill
className="object-contain"
/>
</div>
</section>
);
}

View File

@ -0,0 +1,448 @@
"use client";
import { getListArticle } from "@/service/article";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getAdvertise } from "@/service/advertisement";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
type Advertise = {
id: number;
title: string;
description: string;
placement: string;
contentFileUrl: string;
redirectLink: string;
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
export default function DevelopmentNews() {
const [activeTab, setActiveTab] = useState("comments");
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const [bannerAd, setBannerAd] = useState<Advertise | null>(null);
useEffect(() => {
initStateAdver();
}, []);
async function initStateAdver() {
const req = {
limit: 100,
page: 1,
sort: "desc",
sortBy: "created_at",
isPublish: true,
};
try {
const res = await getAdvertise(req);
const data: Advertise[] = res?.data?.data || [1];
// filter iklan dengan placement = "banner"
const banner = data.find((ad) => ad.placement === "jumbotron");
if (banner) {
setBannerAd(banner);
}
} catch (err) {
console.error("Error fetching advertisement:", err);
}
}
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories, activeTab]);
async function initState() {
let sortBy = "created_at";
if (activeTab === "comments") sortBy = "comment_count";
if (activeTab === "trending") sortBy = "view_count";
// loading();
const req = {
limit: showData,
page: 1,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
const pembangunanArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
// Pagination manually (front-end)
const itemsPerPage = 2;
const calculatedTotalPage = Math.ceil(
pembangunanArticles.length / itemsPerPage
);
const paginatedArticles = pembangunanArticles.slice(
(page - 1) * itemsPerPage,
page * itemsPerPage
);
function truncateText(text: string, wordLimit: number) {
const words = text.split(" ");
if (words.length <= wordLimit) return text;
return words.slice(0, wordLimit).join(" ") + "...";
}
return (
<div className="bg-white grid grid-cols-1 lg:grid-cols-3 gap-6 py-10 px-8">
{/* Left Content */}
<div className="lg:col-span-2 space-y-10">
{paginatedArticles.map((item) => (
<div key={item.id}>
<Link
className="flex flex-col md:flex-row gap-6"
href={`/detail/${item?.id}`}
>
{/* Image + Category */}
<div className="relative w-full md:w-1/2 h-64">
<Image
src={item.thumbnailUrl || "/placeholder.png"}
alt={item.title}
fill
className="object-cover rounded"
/>
<span className="absolute top-3 left-3 bg-yellow-400 text-black px-3 py-1 text-xs font-bold">
{item.categories[0]?.title || "Pembangunan"}
</span>
</div>
{/* Content */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[#16324F] hover:text-blue-600 cursor-pointer">
{item.title}
</h2>
<div className="text-sm text-gray-600 mt-2">
BY{" "}
<span className="text-blue-600 font-semibold">
{item?.customCreatorName || item.createdByName}
</span>{" "}
{new Date(item.createdAt).toLocaleDateString("id-ID")}
</div>
<p className="mt-3 text-gray-700">
{truncateText(item.description, 20)}
</p>
<button className="mt-4 px-4 py-2 border border-gray-400 text-gray-700 hover:bg-black hover:text-white transition">
READ MORE
</button>
</div>
</Link>
</div>
))}
{/* Pagination */}
<div className="flex items-center justify-center gap-2 mt-6">
{/* Previous Button */}
<button
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
disabled={page === 1}
className="px-3 py-1 border"
>
&lt;
</button>
{Array.from({ length: calculatedTotalPage }, (_, i) => i + 1)
.filter((p) => {
return (
p === 1 ||
p === calculatedTotalPage ||
(p >= page - 1 && p <= page + 1)
);
})
.map((p, idx, arr) => {
const prev = arr[idx - 1];
const showEllipsis = prev && p - prev > 1;
return (
<span key={p} className="flex items-center">
{showEllipsis && <span className="px-2">...</span>}
<button
onClick={() => setPage(p)}
className={`px-3 py-1 ${
page === p ? "bg-blue-600 text-white" : "border"
}`}
>
{p}
</button>
</span>
);
})}
<button
onClick={() =>
setPage((prev) => Math.min(prev + 1, calculatedTotalPage))
}
disabled={page === calculatedTotalPage}
className="px-3 py-1 border"
>
&gt;
</button>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Advertisement */}
<div className="w-full h-[400px] relative">
{bannerAd ? (
<a
href={bannerAd.redirectLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full"
>
<div className="relative w-full h-[350px] flex justify-center">
<Image
src={bannerAd.contentFileUrl}
alt={bannerAd.title || "Iklan Banner"}
width={1200} // ukuran dasar untuk responsive
height={350}
className="object-cover w-full h-full"
/>
</div>
</a>
) : (
<Image
src="/kolom.png"
alt="Berita Utama"
width={1200}
height={188}
className="object-contain w-full h-[188px]"
/>
)}
</div>
{/* Connect with us */}
<div>
<h3 className="text-lg font-semibold mb-2">Connect with us</h3>
<div className="h-1 w-20 bg-blue-600 mb-4"></div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-blue-500 text-white text-center p-3">
<p className="text-xl font-bold">138</p>
<p className="text-sm">Followers</p>
</div>
<div className="bg-red-600 text-white text-center p-3">
<p className="text-xl font-bold">205k</p>
<p className="text-sm">Subscribers</p>
</div>
<div className="bg-yellow-400 text-black text-center p-3">
<p className="text-xl font-bold">23.9k</p>
<p className="text-sm">Followers</p>
</div>
</div>
</div>
{/* Tabs */}
<div>
<div className="flex gap-4 border-b">
{["trending", "comments", "latest"].map((tab) => (
<button
key={tab}
className={`pb-2 capitalize ${
activeTab === tab
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600"
}`}
onClick={() => {
setPage(1); // reset page setiap ganti tab
setActiveTab(tab);
}}
>
{tab}
</button>
))}
</div>
<div className="mt-4 space-y-4">
{articles.slice(0, 5).map((item) => (
<div key={item.id} className="flex gap-3 items-center">
<Link
className="flex gap-3 items-center"
href={`/detail/${item?.id}`}
>
<Image
src={item.thumbnailUrl || "/no-image.jpg"}
alt={item.title}
width={80}
height={60}
className="object-cover rounded"
/>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-xs text-gray-500">
{new Date(item.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
</div>
))}
</div>
<div className="">
<h2 className="text-sm border-b-2 border-gray-300 font-bold mb-4">
Recommended
</h2>
<div className=" w-full">
<div className="relative w-full aspect-video mb-5">
<Link href={`/detail/${articles[0]?.id}`}>
<Image
src={
articles[0]?.thumbnailUrl ||
articles[0]?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"articles[0]?.title"}
fill
sizes="(max-width: 1024px) 100vw, 33vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-black/30" />
<div className="absolute bottom-0.5 left-2 text-white">
<h3 className=" font-semibold text-base mb-1">
{articles[0]?.title}
</h3>
<p className=" text-xs mb-2 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
<div className="space-y-5">
{articles?.slice(1, 4).map((article, index) => (
<div key={index}>
<Link
className="flex gap-3"
href={`/detail/${article?.id}`}
>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={
article?.thumbnailUrl ||
article?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"article?.title"}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3">
{article?.title}
</h4>
<p className="text-xs text-gray-500 flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
"use client";
import { getListArticle } from "@/service/article";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
export default function HeaderDevelopment() {
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories]);
async function initState() {
// loading();
const req = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
console.log("develo", res?.data?.data || []);
} finally {
// close();
}
}
const pembangunanArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
const mainArticle = pembangunanArticles[0];
const otherArticles = pembangunanArticles.slice(1, 3);
console.log("otherArticles:", otherArticles);
return (
<section className="max-w-7xl mx-auto bg-white">
<div className="flex flex-col jus items-start bg-[#F2F4F3] w-full overflow-hidden py-6 px-8 gap-3">
<p className="text-gray-400 text-sm">
<Link href="/" className="hover:underline">
Home
</Link>{" "}
{">"}{" "}
<Link href="/category" className="hover:underline">
Category
</Link>{" "}
{">"} <span className="text-black">{categoryLabel}</span>
</p>
<p className="text-3xl font-bold ">Pembangunan</p>
</div>
<div className="pb-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 m-8">
{mainArticle && (
<div className="md:col-span-2 relative">
<Link href={`/detail/${mainArticle.id}`}>
<Image
src={mainArticle.files?.[0]?.fileUrl || "/default-image.jpg"}
alt={mainArticle.title}
width={800}
height={500}
className="w-full h-full max-h-[460px] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/50 to-transparent p-6 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block mb-2 uppercase w-[130px]">
{mainArticle.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h2 className="text-sm md:text-xl lg:text-2xl font-bold text-white leading-snug mb-2 w-full md:w-9/12">
{mainArticle.title}
</h2>
<p className="text-white text-xs">
{mainArticle?.customCreatorName ||
mainArticle.createdByName}{" "}
-{" "}
{new Date(mainArticle.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
)}
<div className="grid grid-rows-2 gap-2">
{otherArticles.map((article, index) => (
<div key={index} className="relative">
<Link href={`/detail/${article.id}`}>
<Image
src={
article.thumbnailUrl ||
article.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={article.title}
width={400}
height={240}
className="w-full h-56 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-4 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block uppercase w-[130px]">
{article.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h3 className="text-sm font-semibold text-white leading-snug mb-1">
{article.title}
</h3>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,118 @@
// components/Footer.tsx
import Link from "next/link";
export default function Footer() {
return (
<footer className="bg-white text-[#666666] text-sm font-sans border-t border-gray-200">
<div className="max-w-[1350px] mx-auto py-6">
{/* Top Menu Links */}
<div className="flex flex-wrap justify-center md:justify-start gap-2 md:gap-3 text-xs mb-8 text-black">
{[
"Tentang Kami",
"Disclaimer",
"Kode Etik Jurnalistik",
"Kebijakan Privasi",
"Pedoman Pemberitaan Media Siber",
].map((item, idx, arr) => (
<span
key={idx}
className="flex items-center gap-2 whitespace-nowrap"
>
<a href="#" className="hover:underline">
{item}
</a>
{idx !== arr.length - 1 && (
<span className="text-gray-400">/</span>
)}
</span>
))}
</div>
<hr className="border-t border-gray-200 my-4" />
{/* Bottom Row */}
<div className="flex flex-col md:flex-row items-center justify-between">
<p className="text-xs text-gray-500 mb-2 md:mb-0">
© 2020 - © Copyright humasRI Team All Rights Reserved .
</p>
<div className="flex space-x-4 text-[#A0A0A0]">
<Link href="#">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
/>
</svg>
</Link>
<Link href="#" className="text-[#A0A0A0]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.91 20.889c8.302 0 12.845-6.885 12.845-12.845c0-.193 0-.387-.009-.58A9.2 9.2 0 0 0 23 5.121a9.2 9.2 0 0 1-2.597.713a4.54 4.54 0 0 0 1.99-2.5a9 9 0 0 1-2.87 1.091A4.5 4.5 0 0 0 16.23 3a4.52 4.52 0 0 0-4.516 4.516c0 .352.044.696.114 1.03a12.82 12.82 0 0 1-9.305-4.718a4.526 4.526 0 0 0 1.4 6.03a4.6 4.6 0 0 1-2.043-.563v.061a4.524 4.524 0 0 0 3.62 4.428a4.4 4.4 0 0 1-1.189.159q-.435 0-.845-.08a4.51 4.51 0 0 0 4.217 3.135a9.05 9.05 0 0 1-5.608 1.936A9 9 0 0 1 1 18.873a12.84 12.84 0 0 0 6.91 2.016"
/>
</svg>
</Link>
<Link href="#" className="text-[#A0A0A0]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
// fill-rule="evenodd"
>
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 4c.855 0 1.732.022 2.582.058l1.004.048l.961.057l.9.061l.822.064a3.8 3.8 0 0 1 3.494 3.423l.04.425l.075.91c.07.943.122 1.971.122 2.954s-.052 2.011-.122 2.954l-.075.91l-.04.425a3.8 3.8 0 0 1-3.495 3.423l-.82.063l-.9.062l-.962.057l-1.004.048A62 62 0 0 1 12 20a62 62 0 0 1-2.582-.058l-1.004-.048l-.961-.057l-.9-.062l-.822-.063a3.8 3.8 0 0 1-3.494-3.423l-.04-.425l-.075-.91A41 41 0 0 1 2 12c0-.983.052-2.011.122-2.954l.075-.91l.04-.425A3.8 3.8 0 0 1 5.73 4.288l.821-.064l.9-.061l.962-.057l1.004-.048A62 62 0 0 1 12 4m-2 5.575v4.85c0 .462.5.75.9.52l4.2-2.425a.6.6 0 0 0 0-1.04l-4.2-2.424a.6.6 0 0 0-.9.52Z"
/>
</g>
</svg>
</Link>
<Link href="#" className="text-[#A0A0A0]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4zm9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3"
/>
</svg>
</Link>
<Link href="#" className="text-[#A0A0A0]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
// fill-rule="evenodd"
d="M17.802 12.298s1.617 1.597 2.017 2.336a.1.1 0 0 1 .018.035q.244.409.123.645c-.135.261-.592.392-.747.403h-2.858c-.199 0-.613-.052-1.117-.4c-.385-.269-.768-.712-1.139-1.145c-.554-.643-1.033-1.201-1.518-1.201a.6.6 0 0 0-.18.03c-.367.116-.833.639-.833 2.032c0 .436-.344.684-.585.684H9.674c-.446 0-2.768-.156-4.827-2.327C2.324 10.732.058 5.4.036 5.353c-.141-.345.155-.533.475-.533h2.886c.387 0 .513.234.601.444c.102.241.48 1.205 1.1 2.288c1.004 1.762 1.621 2.479 2.114 2.479a.53.53 0 0 0 .264-.07c.644-.354.524-2.654.494-3.128c0-.092-.001-1.027-.331-1.479c-.236-.324-.638-.45-.881-.496c.065-.094.203-.238.38-.323c.441-.22 1.238-.252 2.029-.252h.439c.858.012 1.08.067 1.392.146c.628.15.64.557.585 1.943c-.016.396-.033.842-.033 1.367c0 .112-.005.237-.005.364c-.019.711-.044 1.512.458 1.841a.4.4 0 0 0 .217.062c.174 0 .695 0 2.108-2.425c.62-1.071 1.1-2.334 1.133-2.429c.028-.053.112-.202.214-.262a.5.5 0 0 1 .236-.056h3.395c.37 0 .621.056.67.196c.082.227-.016.92-1.566 3.016c-.261.349-.49.651-.691.915c-1.405 1.844-1.405 1.937.083 3.337"
// clip-rule="evenodd"
/>
</svg>
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { getListArticle } from "@/service/article";
import Link from "next/link";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: { title: string }[];
files: { fileUrl: string; file_alt: string }[];
};
export default function Header() {
const [articles, setArticles] = useState<Article[]>([]);
useEffect(() => {
const fetchArticles = async () => {
try {
const req = {
limit: "5", // tampilkan 5 artikel seperti data dummy sebelumnya
page: 1,
search: "",
categorySlug: "",
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
} catch (err) {
console.error("Error fetching articles:", err);
}
};
fetchArticles();
}, []);
return (
<section className="px-4 py-8 bg-white">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-1 max-w-[1350px] mx-auto">
{articles.map((article) => {
const imageUrl =
article.thumbnailUrl ||
article.files?.[0]?.fileUrl ||
"/placeholder.jpg"; // fallback jika gambar tidak ada
const category =
article.categoryName ||
article.categories?.[0]?.title ||
"SUARA WARGA";
// Format tanggal dari createdAt
const date = new Date(article.createdAt).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
});
return (
<div key={article.id}>
<Link
className="border border-gray-200 overflow-hidden shadow-sm bg-white h-[440px]"
href={`/detail/${article?.id}`}
>
<Image
src={imageUrl}
alt={article.title}
width={267}
height={191}
className="w-full h-48 object-cover"
/>
<div className="p-4 text-center">
<p className="text-xs text-gray-400 font-medium tracking-wider uppercase py-2">
{category}
</p>
<h3 className="text-lg font-semibold text-gray-900 mb-4 leading-snug px-9">
{article.title}
</h3>
<p className="text-xs text-gray-400">{date}</p>
</div>
</Link>
</div>
);
})}
</div>
</section>
);
}

View File

@ -0,0 +1,257 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { getListArticle } from "@/service/article";
import Link from "next/link";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
categories: { title: string }[];
customCreatorName?: string;
thumbnailUrl?: string;
files?: { fileUrl: string; file_alt: string }[];
};
export default function Health() {
const [articles, setArticles] = useState<Article[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchArticles();
}, []);
async function fetchArticles() {
setIsLoading(true);
try {
const res = await getListArticle({
limit: "12",
page: 1,
search: "",
categorySlug: "", // ubah sesuai slug kategori kamu
isPublish: true,
sortBy: "created_at",
sort: "desc",
});
setArticles(res?.data?.data || []);
} catch (error) {
console.error("Error fetching Health articles:", error);
} finally {
setIsLoading(false);
}
}
// Format tanggal Indonesia
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
// Mapping artikel ke posisi layout
const leftMain = articles[0];
const leftList = articles.slice(1, 4);
const centerMain = articles[4];
const centerList = articles.slice(5, 8);
const rightMain = articles[8];
const rightList = articles.slice(9, 12);
if (isLoading)
return (
<p className="text-center text-gray-500 py-10">
Memuat berita kesehatan...
</p>
);
return (
<section className="max-w-7xl mx-auto px-4">
<h2 className="text-lg font-bold text-white bg-red-600 inline-block px-4 py-2 border-b-2">
KESEHATAN
</h2>
<h2 className="border-b-2 mb-4"></h2>
{articles.length === 0 ? (
<p className="text-gray-500 text-center py-10">
Belum ada berita di kategori kesehatan.
</p>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* === LEFT COLUMN === */}
{leftMain && (
<div className="w-full">
<Link href={`/detail/${leftMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={leftMain.thumbnailUrl || "/placeholder.jpg"}
alt={leftMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{leftMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{leftMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {leftMain.customCreatorName || leftMain.createdByName} ·{" "}
{formatDate(leftMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{leftMain.description}
</p>
</Link>
<div className="space-y-8">
{leftList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
{/* === CENTER COLUMN === */}
{centerMain && (
<div className="w-full">
<Link href={`/detail/${centerMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={centerMain.thumbnailUrl || "/placeholder.jpg"}
alt={centerMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{centerMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{centerMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {centerMain.customCreatorName || centerMain.createdByName}{" "}
· {formatDate(centerMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{centerMain.description}
</p>
</Link>
<div className="space-y-8">
{centerList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
{/* === RIGHT COLUMN === */}
{rightMain && (
<div className="w-full">
<Link href={`/detail/${rightMain.id}`}>
<div className="relative w-full aspect-video mb-2">
<Image
src={rightMain.thumbnailUrl || "/placeholder.jpg"}
alt={rightMain.title}
fill
className="object-cover"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-xs px-2 py-1">
{rightMain.categories?.[0]?.title}
</span>
</div>
<h3 className="font-semibold text-base mb-2">
{rightMain.title}
</h3>
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
by {rightMain.customCreatorName || rightMain.createdByName} ·{" "}
{formatDate(rightMain.createdAt)}
</p>
<p className="text-[#999999] text-sm font-serif mb-8 line-clamp-3">
{rightMain.description}
</p>
</Link>
<div className="space-y-8">
{rightList.map((item) => (
<div key={item.id}>
<Link className="flex gap-3" href={`/detail/${item.id}`}>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</h4>
<p className="text-xs text-gray-500">
{formatDate(item.createdAt)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="relative my-8 h-[188px] overflow-hidden flex items-center mx-auto border">
<Image
src="/image-kolom.png"
alt="Berita Utama"
fill
className="object-contain"
/>
</div>
</section>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { getListArticle } from "@/service/article";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
export default function HeaderHealth() {
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories]);
async function initState() {
// loading();
const req = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
const healthArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
const mainArticle = healthArticles[0];
const otherArticles = healthArticles.slice(1, 3);
return (
<section className="max-w-7xl mx-auto bg-white">
<div className="flex flex-col items-start bg-[#F2F4F3] w-full overflow-hidden py-6 px-8 gap-3">
<p className="text-gray-400 text-sm">
<Link href="/" className="hover:underline">
Home
</Link>{" "}
{">"} <span className="text-black">{categoryLabel}</span>
</p>
<p className="text-3xl font-bold ">Kesehatan</p>
</div>
<div className="pb-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 m-8">
{mainArticle && (
<div className="md:col-span-2 relative">
<Link href={`/detail/${mainArticle.id}`}>
<Image
src={mainArticle.files?.[0]?.fileUrl || "/default-image.jpg"}
alt={mainArticle.title}
width={800}
height={500}
className="w-full h-full max-h-[460px] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/50 to-transparent p-6 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block mb-2 uppercase w-[130px]">
{mainArticle.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h2 className="text-sm md:text-xl lg:text-2xl font-bold text-white leading-snug mb-2 w-full md:w-9/12">
{mainArticle.title}
</h2>
<p className="text-white text-xs">
{mainArticle?.customCreatorName ||
mainArticle.createdByName}{" "}
-{" "}
{new Date(mainArticle.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
)}
<div className="grid grid-rows-2 gap-2">
{otherArticles.map((article, index) => (
<div key={index} className="relative">
<Link href={`/detail/${article.id}`}>
<Image
src={
article.thumbnailUrl ||
article.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={article.title}
width={400}
height={240}
className="w-full h-56 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-4 flex flex-col justify-end">
<span className="text-xs bg-yellow-400 text-black px-2 py-0.5 inline-block uppercase w-[130px]">
{article.categories?.[0]?.title || "TANPA KATEGORI"}
</span>
<h3 className="text-sm font-semibold text-white leading-snug mb-1">
{article.title}
</h3>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,439 @@
"use client";
import { getListArticle } from "@/service/article";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getAdvertise } from "@/service/advertisement";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: {
title: string;
}[];
files: {
fileUrl: string;
file_alt: string;
}[];
};
const slugToLabel = (slug: string) => {
const mapping: Record<string, string> = {
development: "Pembangunan",
health: "Kesehatan",
"citizen-news": "Berita Warga",
};
return mapping[slug] || slug.charAt(0).toUpperCase() + slug.slice(1);
};
type Advertise = {
id: number;
title: string;
description: string;
placement: string;
contentFileUrl: string;
redirectLink: string;
};
export default function HealthNews() {
const [activeTab, setActiveTab] = useState("comments");
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("100");
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [startDateValue, setStartDateValue] = useState({
startDate: null,
endDate: null,
});
const pathname = usePathname();
const pathSegments = pathname.split("/").filter(Boolean);
const [bannerAd, setBannerAd] = useState<Advertise | null>(null);
useEffect(() => {
initStateAdver();
}, []);
async function initStateAdver() {
const req = {
limit: 100,
page: 1,
sort: "desc",
sortBy: "created_at",
isPublish: true,
};
try {
const res = await getAdvertise(req);
const data: Advertise[] = res?.data?.data || [1];
// filter iklan dengan placement = "banner"
const banner = data.find((ad) => ad.placement === "jumbotron");
if (banner) {
setBannerAd(banner);
}
} catch (err) {
console.error("Error fetching advertisement:", err);
}
}
const categorySlug = pathSegments[1];
const categoryLabel = slugToLabel(categorySlug);
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories, activeTab]);
async function initState() {
let sortBy = "created_at";
if (activeTab === "comments") sortBy = "comment_count";
if (activeTab === "trending") sortBy = "view_count";
// loading();
const req = {
limit: showData,
page: 1,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} finally {
// close();
}
}
const kesehatanArticles = articles.filter((article) =>
article.categories?.some((category) =>
category.title?.toLowerCase().includes("berita warga")
)
);
const itemsPerPage = 2;
const calculatedTotalPage = Math.ceil(
kesehatanArticles.length / itemsPerPage
);
const paginatedArticles = kesehatanArticles.slice(
(page - 1) * itemsPerPage,
page * itemsPerPage
);
function truncateText(text: string, wordLimit: number) {
const words = text.split(" ");
if (words.length <= wordLimit) return text;
return words.slice(0, wordLimit).join(" ") + "...";
}
return (
<div className="bg-white grid grid-cols-1 lg:grid-cols-3 gap-6 py-10 px-8">
<div className="lg:col-span-2 space-y-10">
{paginatedArticles.map((item) => (
<div key={item.id}>
<Link
className="flex flex-col md:flex-row gap-6"
href={`/detail/${item?.id}`}
>
<div className="relative w-full md:w-1/2 h-64">
<Image
src={item.thumbnailUrl || "/placeholder.png"}
alt={item.title}
fill
className="object-cover rounded"
/>
<span className="absolute top-3 left-3 bg-yellow-400 text-black px-3 py-1 text-xs font-bold">
{item.categories[0]?.title || "Pembangunan"}
</span>
</div>
{/* Content */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[#16324F] hover:text-green-600 cursor-pointer">
{item.title}
</h2>
<div className="text-sm text-gray-600 mt-2">
BY{" "}
<span className="text-green-600 font-semibold">
{item?.customCreatorName || item.createdByName || "Admin"}
</span>{" "}
{new Date(item.createdAt).toLocaleDateString("id-ID")}
</div>
<p className="mt-3 text-gray-700">
{truncateText(item.description, 20)}
</p>
<button className="mt-4 px-4 py-2 border border-gray-400 text-gray-700 hover:bg-black hover:text-white transition">
READ MORE
</button>
</div>
</Link>
</div>
))}
{/* Pagination */}
<div className="flex items-center justify-center gap-2 mt-6">
<button
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
disabled={page === 1}
className="px-3 py-1 border"
>
&lt;
</button>
{Array.from({ length: calculatedTotalPage }, (_, i) => i + 1)
.filter((p) => {
return (
p === 1 ||
p === calculatedTotalPage ||
(p >= page - 1 && p <= page + 1)
);
})
.map((p, idx, arr) => {
const prev = arr[idx - 1];
const showEllipsis = prev && p - prev > 1;
return (
<span key={p} className="flex items-center">
{showEllipsis && <span className="px-2">...</span>}
<button
onClick={() => setPage(p)}
className={`px-3 py-1 ${
page === p ? "bg-green-600 text-white" : "border"
}`}
>
{p}
</button>
</span>
);
})}
<button
onClick={() =>
setPage((prev) => Math.min(prev + 1, calculatedTotalPage))
}
disabled={page === calculatedTotalPage}
className="px-3 py-1 border"
>
&gt;
</button>
</div>
</div>
<div className="space-y-6">
<div className="w-full h-[400px] relative">
{bannerAd ? (
<a
href={bannerAd.redirectLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full"
>
<div className="relative w-full h-[350px] flex justify-center">
<Image
src={bannerAd.contentFileUrl}
alt={bannerAd.title || "Iklan Banner"}
width={1200} // ukuran dasar untuk responsive
height={350}
className="object-cover w-full h-full"
/>
</div>
</a>
) : (
<Image
src="/kolom.png"
alt="Berita Utama"
width={1200}
height={188}
className="object-contain w-full h-[188px]"
/>
)}
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Connect with us</h3>
<div className="h-1 w-20 bg-green-600 mb-4"></div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-blue-500 text-white text-center p-3">
<p className="text-xl font-bold">138</p>
<p className="text-sm">Followers</p>
</div>
<div className="bg-red-600 text-white text-center p-3">
<p className="text-xl font-bold">205k</p>
<p className="text-sm">Subscribers</p>
</div>
<div className="bg-yellow-400 text-black text-center p-3">
<p className="text-xl font-bold">23.9k</p>
<p className="text-sm">Followers</p>
</div>
</div>
</div>
<div>
<div className="flex gap-4 border-b">
{["trending", "comments", "latest"].map((tab) => (
<button
key={tab}
className={`pb-2 capitalize ${
activeTab === tab
? "border-b-2 border-green-600 text-green-600"
: "text-gray-600"
}`}
onClick={() => {
setPage(1);
setActiveTab(tab);
}}
>
{tab}
</button>
))}
</div>
<div className="mt-4 space-y-4">
{articles.slice(0, 5).map((item) => (
<div key={item.id} className="flex gap-3 items-center">
<Link
className="flex gap-3 items-center"
href={`/detail/${item?.id}`}
>
<Image
src={item.thumbnailUrl || "/no-image.jpg"}
alt={item.title}
width={80}
height={60}
className="object-cover rounded"
/>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-xs text-gray-500">
{new Date(item.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
</div>
))}
</div>
<div className="">
<h2 className="text-sm border-b-2 border-gray-300 font-bold mb-4">
Recommended
</h2>
<div className=" w-full">
<div className="relative w-full aspect-video mb-5">
<Link href={`/detail/${articles[0]?.id}`}>
<Image
src={
articles[0]?.thumbnailUrl ||
articles[0]?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"articles[0]?.title"}
fill
sizes="(max-width: 1024px) 100vw, 33vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-black/30" />
<div className="absolute bottom-0.5 left-2 text-white">
<h3 className=" font-semibold text-base mb-1">
{articles[0]?.title}
</h3>
<p className=" text-xs mb-2 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
<div className="space-y-5">
{articles?.slice(1, 4).map((article, index) => (
<div key={index}>
<Link
className="flex gap-3"
href={`/detail/${article?.id}`}
>
<div className="relative w-[120px] h-[86px] shrink-0">
<Image
src={
article?.thumbnailUrl ||
article?.files?.[0]?.fileUrl ||
"/default-image.jpg"
}
alt={"article?.title"}
fill
className="object-cover"
/>
</div>
<div>
<h4 className="text-sm font-semibold mb-3">
{article?.title}
</h4>
<p className="text-xs text-gray-500 flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{new Date(articles[0]?.createdAt).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
"use client";
import { getListArticle } from "@/service/article";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: { title: string }[];
files: { fileUrl: string; file_alt: string }[];
};
export default function News() {
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [articles, setArticles] = useState<Article[]>([]);
const [showData, setShowData] = useState("6");
const [search] = useState("");
const [selectedCategories] = useState<any>("");
const [startDateValue] = useState({
startDate: null,
endDate: null,
});
// Fetch data setiap kali page berubah
useEffect(() => {
initState();
}, [page, showData, startDateValue, selectedCategories]);
async function initState() {
const req = {
limit: showData,
page,
search,
categorySlug: Array.from(selectedCategories).join(","),
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
try {
const res = await getListArticle(req);
setArticles(res?.data?.data || []);
setTotalPage(res?.data?.meta?.totalPage || 1);
} catch (err) {
console.error("Error fetching articles:", err);
}
}
const handlePrev = () => {
if (page > 1) setPage((prev) => prev - 1);
};
const handleNext = () => {
if (page < totalPage) setPage((prev) => prev + 1);
};
return (
<section className="max-w-screen-xl mx-auto px-4 py-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Berita Terbaru */}
<div className="lg:col-span-2">
<div className="flex flex-row items-center gap-2 mb-4">
<h2 className="text-lg font-semibold">Berita Terbaru</h2>
<div className="flex-grow border-t-2 border-gray-300 rounded-md" />
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{articles.length > 0 ? (
articles.map((item) => (
<div key={item.id} className="group cursor-pointer">
<Link href={`/detail/${item.id}`}>
<div className="relative w-full aspect-[3/2] overflow-hidden">
<Image
src={
item.thumbnailUrl ||
item.files?.[0]?.fileUrl ||
"/placeholder.jpg"
}
alt={item.files?.[0]?.file_alt || item.title}
fill
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
/>
<span className="absolute bottom-2 left-2 bg-yellow-400 text-black text-[10px] px-2 py-1">
{item.categoryName ||
item.categories?.[0]?.title ||
"Umum"}
</span>
</div>
<h3 className="mt-3 font-bold leading-snug line-clamp-2">
{item.title}
</h3>
<p className="text-xs text-[#A0A0A0] mt-1 flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-gray-400"
>
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</svg>
{new Date(item.createdAt).toLocaleDateString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
</Link>
</div>
))
) : (
<p className="col-span-3 text-center text-gray-500">
Tidak ada artikel tersedia.
</p>
)}
</div>
{/* Pagination */}
<div className="mt-8 flex flex-wrap gap-2 justify-start">
<button
onClick={handlePrev}
disabled={page === 1}
className={`border px-3 py-1 text-xs rounded-sm ${
page === 1
? "opacity-50 cursor-not-allowed"
: "hover:bg-gray-100"
}`}
>
PREV
</button>
<button
onClick={handleNext}
disabled={page >= totalPage}
className={`border px-3 py-1 text-xs rounded-sm ${
page >= totalPage
? "opacity-50 cursor-not-allowed"
: "hover:bg-gray-100"
}`}
>
NEXT
</button>
</div>
</div>
{/* Twitter Section */}
<div>
<h3 className="text-xl font-semibold border-b-2 border-gray-300 mb-4">
Twitter @ArahNegeri
</h3>
{/* Embed atau konten lain */}
</div>
</div>
{/* Banner bawah */}
<div className="relative my-5 h-[188px] overflow-hidden flex items-center mx-auto border">
<Image
src="/image-kolom.png"
alt="Berita Utama"
fill
className="object-contain"
/>
</div>
</section>
);
}

View File

@ -0,0 +1,144 @@
"use client";
import { Lock, Menu, Search } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "../ui/button";
export default function Navbar() {
return (
<>
<div className="w-full bg-white ">
<div className="flex flex-row items-center border-b-2 border-black mx-5">
<div className="relative w-full h-[113px]">
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/Warga.png"
alt="Kritik Tajam Logo"
width={100}
height={93}
className="object-contain"
/>
</div>
</div>
<div className="flex items-center space-x-4 text-black mt-2 md:mt-0 mr-3 md:mr-3 lg:mr-3 xl:mr-0 ">
<Link href="#">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
/>
</svg>
</Link>
<Link href="#">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 256 209"
>
<path
fill="#55acee"
d="M256 25.45a105 105 0 0 1-30.166 8.27c10.845-6.5 19.172-16.793 23.093-29.057a105.2 105.2 0 0 1-33.351 12.745C205.995 7.201 192.346.822 177.239.822c-29.006 0-52.523 23.516-52.523 52.52c0 4.117.465 8.125 1.36 11.97c-43.65-2.191-82.35-23.1-108.255-54.876c-4.52 7.757-7.11 16.78-7.11 26.404c0 18.222 9.273 34.297 23.365 43.716a52.3 52.3 0 0 1-23.79-6.57q-.004.33-.003.661c0 25.447 18.104 46.675 42.13 51.5a52.6 52.6 0 0 1-23.718.9c6.683 20.866 26.08 36.05 49.062 36.475c-17.975 14.086-40.622 22.483-65.228 22.483c-4.24 0-8.42-.249-12.529-.734c23.243 14.902 50.85 23.597 80.51 23.597c96.607 0 149.434-80.031 149.434-149.435q0-3.417-.152-6.795A106.8 106.8 0 0 0 256 25.45"
/>
</svg>
</Link>
<Link href="#">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 72 72"
>
<path
fill="#ea5a47"
d="M63.874 21.906a7.31 7.31 0 0 0-5.144-5.177C54.193 15.505 36 15.505 36 15.505s-18.193 0-22.73 1.224a7.31 7.31 0 0 0-5.144 5.177C6.91 26.472 6.91 36 6.91 36s0 9.528 1.216 14.095a7.31 7.31 0 0 0 5.144 5.177C17.807 56.495 36 56.495 36 56.495s18.193 0 22.73-1.223a7.31 7.31 0 0 0 5.144-5.177C65.09 45.528 65.09 36 65.09 36s0-9.528-1.216-14.094"
/>
<path fill="#fff" d="M30.05 44.65L45.256 36L30.05 27.35Z" />
<g
fill="none"
stroke="#000"
// stroke-miterlimit="10"
// stroke-width="2"
>
<path d="M63.874 21.906a7.31 7.31 0 0 0-5.144-5.177C54.193 15.505 36 15.505 36 15.505s-18.193 0-22.73 1.224a7.31 7.31 0 0 0-5.144 5.177C6.91 26.472 6.91 36 6.91 36s0 9.528 1.216 14.095a7.31 7.31 0 0 0 5.144 5.177C17.807 56.495 36 56.495 36 56.495s18.193 0 22.73-1.223a7.31 7.31 0 0 0 5.144-5.177C65.09 45.528 65.09 36 65.09 36s0-9.528-1.216-14.094" />
<path
// stroke-linecap="round"
// stroke-linejoin="round"
d="M30.05 44.65L45.256 36L30.05 27.35Z"
/>
</g>
</svg>
</Link>
<Link href="#" className="text-red-500">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4zm9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3"
/>
</svg>
</Link>
<Link href="#" className="text-blue-800">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
// fill-rule="evenodd"
d="M17.802 12.298s1.617 1.597 2.017 2.336a.1.1 0 0 1 .018.035q.244.409.123.645c-.135.261-.592.392-.747.403h-2.858c-.199 0-.613-.052-1.117-.4c-.385-.269-.768-.712-1.139-1.145c-.554-.643-1.033-1.201-1.518-1.201a.6.6 0 0 0-.18.03c-.367.116-.833.639-.833 2.032c0 .436-.344.684-.585.684H9.674c-.446 0-2.768-.156-4.827-2.327C2.324 10.732.058 5.4.036 5.353c-.141-.345.155-.533.475-.533h2.886c.387 0 .513.234.601.444c.102.241.48 1.205 1.1 2.288c1.004 1.762 1.621 2.479 2.114 2.479a.53.53 0 0 0 .264-.07c.644-.354.524-2.654.494-3.128c0-.092-.001-1.027-.331-1.479c-.236-.324-.638-.45-.881-.496c.065-.094.203-.238.38-.323c.441-.22 1.238-.252 2.029-.252h.439c.858.012 1.08.067 1.392.146c.628.15.64.557.585 1.943c-.016.396-.033.842-.033 1.367c0 .112-.005.237-.005.364c-.019.711-.044 1.512.458 1.841a.4.4 0 0 0 .217.062c.174 0 .695 0 2.108-2.425c.62-1.071 1.1-2.334 1.133-2.429c.028-.053.112-.202.214-.262a.5.5 0 0 1 .236-.056h3.395c.37 0 .621.056.67.196c.082.227-.016.92-1.566 3.016c-.261.349-.49.651-.691.915c-1.405 1.844-1.405 1.937.083 3.337"
// clip-rule="evenodd"
/>
</svg>
</Link>
</div>
</div>
<div className=" mx-auto flex items-center justify-between px-4 py-3">
{/* Kiri: Hamburger */}
<div className="flex items-center">
<Menu className="w-6 h-6 text-black cursor-pointer" />
</div>
{/* Tengah: Menu Utama */}
<nav className="hidden md:flex space-x-8 font-bold text-sm text-black">
<a href="/" className="relative">
<span className="border-b-2 border-black pb-1">BERANDA</span>
</a>
<a href="/category/citizen-news" className="hover:text-gray-700">
BERITA WARGA
</a>
<a href="/category/development" className="hover:text-gray-700">
PEMBANGUNAN
</a>
<a href="/category/health" className="hover:text-gray-700">
KESEHATAN
</a>
</nav>
{/* Kanan: Search Icon */}
<div className="flex items-center gap-3">
<Search className="w-5 h-5 text-black cursor-pointer" />
<Link href="/auth" className="font-medium hover:underline">
<Button>
{" "}
<Lock className="w-4 h-4" />
Login
</Button>
</Link>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,353 @@
"use client";
import { ChevronLeft, ChevronRight, Clock, MessageCircle } from "lucide-react";
import { Card } from "../ui/card";
import Image from "next/image";
import { getListArticle } from "@/service/article";
import { useState, useEffect } from "react";
import latestNews from "./latest-news";
type Article = {
id: number;
title: string;
description: string;
categoryName: string;
createdAt: string;
createdByName: string;
customCreatorName: string;
thumbnailUrl: string;
categories: { title: string }[];
files: { fileUrl: string; file_alt: string }[];
image: string;
author: string;
category: string;
date: string;
expert: string;
};
export default function Beranda() {
const [articles, setArticles] = useState<Article[]>([]);
const [mainNews, setMainNews] = useState<Article | null>(null);
const [latestNewsMid, setLatestNewsMid] = useState<Article[]>([]);
const [recentNews, setRecentNews] = useState<Article[]>([]);
const [latestNews, setLatestNews] = useState<Article[]>([]);
useEffect(() => {
const fetchArticles = async () => {
try {
const req = {
limit: "6", // ambil 6 berita, 1 untuk main + 5 untuk lainnya
page: 1,
search: "",
categorySlug: "",
sort: "desc",
isPublish: true,
sortBy: "created_at",
};
const res = await getListArticle(req);
const data = res?.data?.data || [];
setArticles(data);
setMainNews(data[0] || null);
setLatestNewsMid(data.slice(1, 5));
setRecentNews(res?.data?.data || []);
setLatestNews(res?.data?.data || []);
} catch (err) {
console.error("Error fetching articles:", err);
}
};
fetchArticles();
}, []);
const formatDate = (dateString?: string) => {
if (!dateString) return "";
return new Date(dateString).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
});
};
return (
<section className="max-w-[1600px] px-4 lg:mx-auto py-8 flex flex-col md:flex-wrap lg:flex-row justify-between gap-6 ">
{/* Berita Terpopuler */}
<aside className="w-full md:w-full lg:w-full xl:w-[312px] space-y-4 shrink-0">
<Card className="rounded-none px-4 py-4">
<h2 className="text-lg font-semibold border-b pb-2">
Berita Terpopuler
</h2>
<ul className="space-y-4">
{articles.map((news) => {
const imageUrl =
news.thumbnailUrl ||
news.files?.[0]?.fileUrl ||
"/placeholder.jpg";
return (
<li key={news.id} className="flex gap-3 items-start">
<Image
src={imageUrl}
width={100}
height={71}
alt={news.title}
className="w-[100px] h-[71px] object-cover"
/>
<p className="text-sm leading-snug font-semibold px-2">
{news.title}
</p>
</li>
);
})}
</ul>
<div className="flex gap-2 pt-4">
<button className="text-xs border px-2 py-1 flex items-center gap-1">
<ChevronLeft size={14} /> Prev
</button>
<button className="text-xs border px-2 py-1 flex items-center gap-1">
Next <ChevronRight size={14} />
</button>
</div>
</Card>
<div className="relative w-auto max-w-full h-[300px] overflow-hidden flex items-center mx-auto border my-6 rounded">
<Image
src="/kolom.png"
alt="Berita Utama"
fill
className="object-contain rounded"
/>
</div>
</aside>
{/* Berita Utama */}
<main className="w-full md:w-full lg:w-full xl:w-[655px] space-y-6 shrink-0">
{mainNews && (
<Card className="border p-8 shadow-sm bg-white rounded-none gap-1">
<p className="text-xs text-gray-500 uppercase mb-1">Beranda</p>
<h1 className="text-xl font-bold mt-1">{mainNews.title}</h1>
<p className="text-[10px] md:text-sm text-gray-500 mt-1 flex items-center gap-3">
BY{" "}
<span className="text-black font-semibold text-[10px] md:text-sm">
{mainNews.customCreatorName ||
mainNews.createdByName ||
"Redaksi"}
</span>{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
{formatDate(mainNews.createdAt)}
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
0
</p>
<p className="text-sm text-[#666666] mt-2">
{mainNews.description}
</p>
<button className="mt-4 text-xs w-3/12 border px-3 hover:bg-gray-100 transition py-2">
READ MORE
</button>
</Card>
)}
<div className="flex gap-2">
<button className="text-xs border px-3 py-1 flex items-center gap-1">
<ChevronLeft size={14} /> Prev
</button>
<button className="text-xs border px-3 py-1 flex items-center gap-1">
Next <ChevronRight size={14} />
</button>
</div>
{/* Grid Berita Tambahan */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{latestNewsMid.map((news) => {
const imageUrl =
news.thumbnailUrl ||
news.files?.[0]?.fileUrl ||
"/placeholder.jpg";
const category =
news.categoryName || news.categories?.[0]?.title || "Berita";
return (
<div
key={news.id}
className="bg-white shadow-md rounded-none overflow-hidden border"
>
<div className="relative">
<Image
src={imageUrl}
alt={news.title}
width={400}
height={223}
className="w-full h-[223px] object-cover"
/>
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 bg-black text-white text-[10px] px-2 py-[2px] uppercase">
{category}
</span>
</div>
<div className="p-4 text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-3 leading-snug px-9">
{news.title}
</h3>
<div className="flex flex-row items-center justify-center text-gray-500 gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16m0 2a1 1 0 0 1 .993.883L13 7v4.586l2.707 2.707a1 1 0 0 1-1.32 1.497l-.094-.083l-3-3a1 1 0 0 1-.284-.576L11 12V7a1 1 0 0 1 1-1"
/>
</g>
</svg>{" "}
<p className="text-xs text-gray-400">
{formatDate(news.createdAt)}
</p>
</div>
</div>
</div>
);
})}
</div>
{/* Recent News */}
<div className="space-y-4">
<Card className="px-5 py-5 rounded-none">
<h2 className="text-sm font-semibold border-b pb-1 border-black inline-block">
Recent News
</h2>
{recentNews.map((item, i) => (
<div key={i} className="flex flex-col sm:flex-row gap-4 mt-4">
<Image
src={item.thumbnailUrl || "/placeholder.jpg"}
alt={item.title}
width={220}
height={157}
className="w-full sm:w-[220px] h-[157px] object-cover"
/>
<div className="flex-1">
<h3 className="text-base font-bold">{item.title}</h3>
<p className="text-xs text-gray-500 mt-1 flex flex-wrap items-center gap-2 mb-3">
BY{" "}
<span className="font-semibold text-black">
{item.customCreatorName ||
item.createdByName ||
"Redaksi"}
</span>
<Clock className="w-3 h-3" />
{new Date(item.createdAt).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
})}
<MessageCircle className="w-3 h-3" />0
</p>
<p className="text-sm text-gray-600 line-clamp-3">
{item.description || ""}
</p>
</div>
</div>
))}
{/* Pagination */}
<div className="flex gap-2">
<button className="text-xs border px-3 py-1 flex items-center gap-1">
<ChevronLeft size={14} /> Prev
</button>
<button className="text-xs border px-3 py-1 flex items-center gap-1">
Next <ChevronRight size={14} />
</button>
</div>
</Card>
{/* Banner Iklan */}
<div className="relative my-5 h-[125px] overflow-hidden flex items-center mx-auto border">
<Image
src="/image-kolom.png"
alt="Berita Utama"
fill
className="object-cover"
/>
</div>
</div>
{/* Pagination */}
</main>
{/* Berita Terbaru */}
<aside className="w-full md:w-full lg:w-full xl:w-[312px] space-y-6 shrink-0">
{latestNews.map((news, i) => (
<Card
key={i}
className="rounded-none overflow-hidden bg-white shadow-sm p-0 gap-1"
>
<div className="relative">
{news.thumbnailUrl && (
<Image
src={news.thumbnailUrl}
alt={news.title}
width={600}
height={224}
className="w-full h-[224px] object-cover"
/>
)}
{news.categoryName && (
<span className="absolute top-2 left-2 bg-black text-white text-[10px] px-2 py-1 rounded-none uppercase tracking-wide">
{news.categoryName}
</span>
)}
</div>
<div className="p-4">
<h3 className="text-base font-bold leading-snug">{news.title}</h3>
<p className="text-xs text-gray-500 mt-2 font-medium mb-4">
BY{" "}
<span className="text-black">
{news.customCreatorName || news.createdByName || "Redaksi"}
</span>{" "}
·{" "}
{new Date(news.createdAt).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
})}
</p>
<p className="text-sm text-gray-600 mt-2 line-clamp-5">
{news.description || ""}
</p>
</div>
</Card>
))}
</aside>
</section>
);
}

View File

@ -0,0 +1,120 @@
import { motion } from "framer-motion";
import { useState, Dispatch, SetStateAction } from "react";
export type OptionProps = {
Icon: any;
title: string;
selected?: string;
setSelected?: Dispatch<SetStateAction<string>>;
open: boolean;
notifs?: number;
active?: boolean;
};
const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: OptionProps) => {
const [hovered, setHovered] = useState(false);
const isActive = active ?? selected === title;
return (
<motion.button
layout
onClick={() => setSelected?.(title)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative flex h-12 w-full px-3 items-center rounded-xl transition-all duration-200 cursor-pointer group ${
isActive
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800"
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{/* Active indicator */}
{isActive && (
<motion.div
layoutId="activeIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-white rounded-r-full shadow-sm"
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
transition={{ duration: 0.2 }}
/>
)}
<motion.div
layout
className={`h-full flex items-center justify-center ${
open ? "w-12" : "w-full"
}`}
>
<div className={`text-lg transition-all duration-200 ${
isActive
? "text-white"
: "text-slate-500 group-hover:text-slate-700"
}`}>
<Icon />
</div>
</motion.div>
{open && (
<motion.span
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
className={`text-sm font-medium transition-colors duration-200 ${
isActive ? "text-white" : "text-slate-700"
}`}
>
{title}
</motion.span>
)}
{/* Tooltip for collapsed state */}
{!open && hovered && (
<motion.div
initial={{ opacity: 0, x: 8, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 8, scale: 0.8 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="absolute left-full ml-3 whitespace-nowrap rounded-lg bg-slate-800 px-3 py-2 text-sm text-white shadow-xl z-50"
>
<div className="relative">
{title}
{/* Tooltip arrow */}
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
</div>
</motion.div>
)}
{/* Notification badge */}
{notifs && open && (
<motion.span
initial={{ scale: 0, opacity: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, type: "spring" }}
className={`absolute right-3 top-1/2 -translate-y-1/2 size-5 rounded-full text-xs font-semibold flex items-center justify-center ${
isActive
? "bg-white text-emerald-500"
: "bg-red-500 text-white"
}`}
>
{notifs}
</motion.span>
)}
{/* Hover effect overlay */}
{hovered && !isActive && (
<motion.div
layoutId="hoverOverlay"
className="absolute inset-0 bg-gradient-to-r from-slate-100/50 to-slate-200/50 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</motion.button>
);
};
export default Option;

View File

@ -0,0 +1,625 @@
"use client";
import React, { Dispatch, SetStateAction, useState, useEffect } from "react";
import Image from "next/image";
import { Icon } from "@iconify/react";
import Link from "next/link";
import DashboardContainer from "../main/dashboard/dashboard-container";
import { usePathname } from "next/navigation";
import Option from "./option";
import { useTheme } from "../layout/theme-context";
import { AnimatePresence, motion } from "framer-motion";
interface RetractingSidebarProps {
sidebarData: boolean;
updateSidebarData: (newData: boolean) => void;
}
const sidebarSections = [
{
title: "Dashboard",
items: [
{
title: "Dashboard",
icon: () => (
<Icon icon="material-symbols:dashboard" className="text-lg" />
),
link: "/admin/dashboard",
},
],
},
{
title: "Content Management",
items: [
{
title: "Articles",
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
link: "/admin/article",
},
{
title: "Categories",
icon: () => <Icon icon="famicons:list-outline" className="text-lg" />,
link: "/admin/master-category",
},
// {
// title: "Majalah",
// icon: () => <Icon icon="emojione-monotone:newspaper" className="text-lg" />,
// link: "/admin/magazine",
// },
{
title: "Advertisements",
icon: () => <Icon icon="ic:round-ads-click" className="text-lg" />,
link: "/admin/advertise",
},
// {
// title: "Komentar",
// icon: () => <Icon icon="material-symbols:comment-outline-rounded" className="text-lg" />,
// link: "/admin/komentar",
// },
],
},
{
title: "System",
items: [
{
title: "Static Pages",
icon: () => <Icon icon="fluent-mdl2:page-solid" className="text-lg" />,
link: "/admin/static-page",
},
{
title: "User Management",
icon: () => <Icon icon="ph:users-three-fill" className="text-lg" />,
link: "/admin/master-user",
},
],
},
];
export const RetractingSidebar = ({
sidebarData,
updateSidebarData,
}: RetractingSidebarProps) => {
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<>
{/* DESKTOP SIDEBAR */}
<AnimatePresence mode="wait">
<motion.nav
key="desktop-sidebar"
layout
className="hidden md:flex sticky top-0 h-screen shrink-0 bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 border-r border-slate-200/60 dark:border-slate-700/60 shadow-lg backdrop-blur-sm flex-col justify-between"
style={{
width: sidebarData ? "280px" : "80px",
}}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<SidebarContent
open={sidebarData}
pathname={pathname}
updateSidebarData={updateSidebarData}
/>
</motion.nav>
</AnimatePresence>
{/* Desktop Toggle Button - appears when sidebar is collapsed */}
<AnimatePresence>
{!sidebarData && (
<motion.button
key="desktop-toggle"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="hidden md:flex fixed top-4 left-20 z-40 p-3 bg-white rounded-xl shadow-lg border border-slate-200/60 hover:shadow-xl transition-all duration-200 hover:bg-slate-50"
onClick={() => updateSidebarData(true)}
>
<Icon
icon="heroicons:chevron-right"
className="w-5 h-5 text-slate-600"
/>
</motion.button>
)}
</AnimatePresence>
{/* Mobile Toggle Button */}
<AnimatePresence>
{!sidebarData && (
<motion.button
key="mobile-toggle"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
className="md:hidden fixed top-4 left-4 z-50 p-3 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200/60 dark:border-slate-700/60 hover:shadow-xl transition-all duration-200"
onClick={() => updateSidebarData(true)}
>
<Icon
icon="heroicons:chevron-right"
className="w-6 h-6 text-slate-600"
/>
</motion.button>
)}
</AnimatePresence>
{/* MOBILE SIDEBAR */}
<AnimatePresence>
{sidebarData && (
<motion.div
key="mobile-sidebar"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.3 }}
className="fixed top-0 left-0 z-50 w-[280px] h-full bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 p-4 flex flex-col md:hidden shadow-2xl backdrop-blur-sm"
>
{/* <button onClick={() => updateSidebarData(false)} className="mb-4 self-end text-zinc-500">
</button> */}
<SidebarContent
open={true}
pathname={pathname}
updateSidebarData={updateSidebarData}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
};
const SidebarContent = ({
open,
pathname,
updateSidebarData,
}: {
open: boolean;
pathname: string;
updateSidebarData: (newData: boolean) => void;
}) => {
const { theme, toggleTheme } = useTheme();
const [username, setUsername] = useState("");
useEffect(() => {
// Ambil cookie secara client-side
const cookies = document.cookie.split("; ").reduce((acc: any, cur) => {
const [key, value] = cur.split("=");
acc[key] = value;
return acc;
}, {});
setUsername(cookies.username || "Guest");
}, []);
return (
<div className="flex flex-col h-full">
{/* SCROLLABLE TOP SECTION */}
<div className="flex-1 overflow-y-auto">
{/* HEADER SECTION */}
<div className="flex flex-col space-y-6">
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-4 py-6">
<Link href="/" className="flex items-center space-x-3">
<div className="relative">
<img
src="/Warga.png"
className="w-10 h-10 rounded-lg shadow-sm"
/>
<div className="absolute -inset-1 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg opacity-20 blur-sm"></div>
</div>
{open && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="flex flex-col"
>
<span className="text-lg font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
Warga Bicara
</span>
<span className="text-xs text-slate-500">Admin Panel</span>
</motion.div>
)}
</Link>
{open && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors duration-200 group"
onClick={() => updateSidebarData(false)}
>
<Icon
icon="heroicons:chevron-left"
className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors"
/>
</motion.button>
)}
</div>
{/* Navigation Sections */}
<div className="space-y-3 px-3 pb-6">
{sidebarSections.map((section, sectionIndex) => (
<motion.div
key={section.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + sectionIndex * 0.1 }}
className="space-y-3"
>
{open && (
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 + sectionIndex * 0.1 }}
className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-3"
>
{section.title}
</motion.h3>
)}
<div className="space-y-2">
{section.items.map((item, itemIndex) => (
<Link href={item.link} key={item.title}>
<Option
Icon={item.icon}
title={item.title}
active={pathname === item.link}
open={open}
/>
</Link>
))}
</div>
</motion.div>
))}
</div>
</div>
</div>
{/* FIXED BOTTOM SECTION */}
<div className="flex-shrink-0 space-y-1 border-t border-slate-200/60 dark:border-slate-700/60 bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm">
{/* Divider */}
{/* <div className="px-3 pb-2">
<div className="h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent"></div>
</div> */}
{/* Theme Toggle */}
<div className="px-3 pt-1">
<motion.button
onClick={toggleTheme}
className={`relative flex h-12 w-full items-center rounded-xl transition-all duration-200 cursor-pointer group ${
open ? "px-3" : "justify-center"
} ${
theme === "dark"
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800 dark:text-slate-300 dark:hover:bg-slate-700/50"
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<motion.div
className={`h-full flex items-center justify-center ${
open ? "w-12" : "w-full"
}`}
>
<div
className={`text-lg transition-all duration-200 ${
theme === "dark"
? "text-white"
: "text-slate-500 group-hover:text-slate-700 dark:text-slate-400 dark:group-hover:text-slate-200"
}`}
>
{theme === "dark" ? (
<Icon icon="solar:sun-bold" className="text-lg" />
) : (
<Icon icon="solar:moon-bold" className="text-lg" />
)}
</div>
</motion.div>
{open && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
className={`text-sm font-medium transition-colors duration-200 ${
theme === "dark"
? "text-white"
: "text-slate-700 dark:text-slate-300"
}`}
>
{theme === "dark" ? "Light Mode" : "Dark Mode"}
</motion.span>
)}
</motion.button>
</div>
{/* Settings */}
<div className="px-3">
<Link href="/settings">
<Option
Icon={() => (
<Icon icon="lets-icons:setting-fill" className="text-lg" />
)}
title="Settings"
active={pathname === "/settings"}
open={open}
/>
</Link>
</div>
{/* User Profile */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="px-3 py-3 border-t border-slate-200/60"
>
<div
className={`${
open
? "flex items-center space-x-3"
: "flex items-center justify-center"
} p-3 rounded-xl bg-gradient-to-r from-slate-50 to-slate-100/50 hover:from-slate-100 hover:to-slate-200/50 transition-all duration-200 cursor-pointer group`}
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold text-sm shadow-lg">
A
</div>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
</div>
{open && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="flex-1 min-w-0"
>
<p className="text-sm font-medium text-slate-800 truncate">
{username}
</p>
<Link href="/auth">
<p className="text-xs text-slate-500 hover:text-blue-600 transition-colors duration-200">
Sign out
</p>
</Link>
</motion.div>
)}
</div>
</motion.div>
{/* Expand Button for Collapsed State */}
{/* {!open && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.6 }}
className="px-3 pt-2"
>
<button
onClick={() => updateSidebarData(true)}
className="w-full p-3 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg transition-all duration-200 hover:shadow-xl group"
>
<div className="flex items-center justify-center">
<Icon
icon="heroicons:chevron-right"
className="w-5 h-5 group-hover:scale-110 transition-transform duration-200"
/>
</div>
</button>
</motion.div>
)} */}
</div>
</div>
);
};
const Sidebar = () => {
const [open, setOpen] = useState(true);
const pathname = usePathname();
const [username, setUsername] = useState("");
useEffect(() => {
// Ambil cookie secara client-side
const cookies = document.cookie.split("; ").reduce((acc: any, cur) => {
const [key, value] = cur.split("=");
acc[key] = value;
return acc;
}, {});
setUsername(cookies.username || "Guest");
}, []);
return (
<motion.nav
layout
className="sticky top-0 h-screen shrink-0 border-r border-slate-300 bg-white p-1 hidden md:flex flex-col justify-between"
style={{
width: open ? "120px" : "90px",
}}
>
{/* BAGIAN ATAS */}
<div>
{!open && (
<div className="w-full flex justify-center items-center">
<button
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
onClick={() => setOpen(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="m10 17l5-5m0 0l-5-5"
/>
</svg>
</button>
</div>
)}
<div
className={`flex ${
open ? "justify-between" : "justify-center"
} w-full items-center px-2`}
>
<Link href="/" className="flex flex-row items-center gap-3 font-bold">
<img src="/assets/icon/Logo.png" className="w-20" />
</Link>
{open && (
<button
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
onClick={() => setOpen(false)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="m14 7l-5 5m0 0l5 5"
/>
</svg>
</button>
)}
</div>
<div className="space-y-1">
{sidebarSections.map((section) => (
<div key={section.title}>
<p className="font-bold text-[14px] py-2">{section.title}</p>
{section.items.map((item) => (
<Link href={item.link} key={item.title}>
<Option
Icon={item.icon}
title={item.title}
active={pathname === item.link}
open={open}
/>
</Link>
))}
</div>
))}
</div>
</div>
{/* BAGIAN BAWAH */}
<div className="space-y-1">
<Option
Icon={() => <Icon icon="solar:moon-bold" className="text-lg" />}
title="Theme"
active={false}
open={open}
/>
<Link href="/settings">
<Option
Icon={() => (
<Icon icon="lets-icons:setting-fill" className="text-lg" />
)}
title="Settings"
active={pathname === "/settings"}
open={open}
/>
</Link>{" "}
<div className="flex flex-row gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="34"
height="34"
viewBox="0 0 24 24"
>
<g fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="6" r="4" />
<path d="M20 17.5c0 2.485 0 4.5-8 4.5s-8-2.015-8-4.5S7.582 13 12 13s8 2.015 8 4.5Z" />
</g>
</svg>
<div className="flex flex-col gap-0.5 text-xs">
<p>{username}</p>
<p className="underline">Logout</p>
</div>
</div>
</div>
</motion.nav>
);
};
export default Sidebar;
const TitleSection = ({ open }: { open: boolean }) => {
return (
<div className="flex cursor-pointer items-center justify-between rounded-md transition-colors hover:bg-slate-100">
<div className="flex items-center">
<motion.div
layout
initial={{ opacity: 0, y: 12, scale: 0.5 }}
animate={
open
? { opacity: 1, y: 0, scale: 1 }
: { opacity: 1, y: 0, scale: 0.5 }
}
transition={{ delay: 0.125 }}
>
<Image
src="/assets/icon/Logo.png"
alt="logo"
width={1920}
height={1080}
className="w-full h-fit"
/>
</motion.div>
</div>
{/* {open && <FiChevronDown className="mr-2" />} */}
</div>
);
};
const ToggleClose = ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => {
return (
<motion.button layout onClick={() => setOpen((pv) => !pv)}>
<div className="flex justify-center items-center pt-2">
<motion.div layout className="grid size-10 text-lg">
{/* <FiChevronsRight
className={`transition-transform ${open && "rotate-180"}`}
/> */}
</motion.div>
{/* {open && (
<motion.span layout initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.125 }} className="text-xs font-medium">
Hide
</motion.span>
)} */}
</div>
</motion.button>
);
};
const ExampleContent = () => (
<div>
<DashboardContainer />
</div>
);

View File

@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
import React, { ReactNode } from "react";
import { SidebarProvider } from "./sidebar-context";
import { ThemeProvider } from "./theme-context";
import { Breadcrumbs } from "./breadcrumbs";
import { BurgerButtonIcon } from "../icons";
import { RetractingSidebar } from "../landing-page/retracting-sidedar";
import { motion, AnimatePresence } from "framer-motion";
export const AdminLayout = ({ children }: { children: ReactNode }) => {
const [isOpen, setIsOpen] = useState(true);
const [hasMounted, setHasMounted] = useState(false);
const updateSidebarData = (newData: boolean) => {
setIsOpen(newData);
};
// Hooks
useEffect(() => {
setHasMounted(true);
}, []);
// Render loading state until mounted
if (!hasMounted) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<ThemeProvider>
<SidebarProvider>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
<div className="flex h-screen overflow-hidden">
<RetractingSidebar
sidebarData={isOpen}
updateSidebarData={updateSidebarData}
/>
<AnimatePresence mode="wait">
<motion.div
key="main-content"
className="flex-1 flex flex-col overflow-hidden"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header */}
<motion.header
className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-b border-slate-200/60 dark:border-slate-700/60 shadow-sm"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
>
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center space-x-4">
<button
className="md:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200"
onClick={() => updateSidebarData(true)}
>
<BurgerButtonIcon />
</button>
<Breadcrumbs />
</div>
</div>
</motion.header>
{/* Main Content */}
<motion.main
className="flex-1 overflow-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
>
<div className="h-full">
{children}
</div>
</motion.main>
</motion.div>
</AnimatePresence>
</div>
</div>
</SidebarProvider>
</ThemeProvider>
);
};

View File

@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
ArticleIcon,
DashboardIcon,
MagazineIcon,
MasterCategoryIcon,
MasterRoleIcon,
MasterUsersIcon,
StaticPageIcon,
} from "../icons/sidebar-icon";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "../ui/breadcrumb";
import React from "react";
import { motion } from "framer-motion";
export const Breadcrumbs = () => {
const [currentPage, setCurrentPage] = useState<React.Key>("");
const [mounted, setMounted] = useState(false);
const router = useRouter();
const pathname = usePathname();
const pathnameSplit = pathname.split("/");
pathnameSplit.shift();
const pathnameTransformed = pathnameSplit.map((item) => {
const words = item.split("-");
const capitalizedWords = words.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1)
);
return capitalizedWords.join(" ");
});
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
setCurrentPage(pathnameSplit[pathnameSplit.length - 1]);
}, [pathnameSplit]);
const handleAction = (key: any) => {
const keyIndex = pathnameSplit.indexOf(key);
const combinedPath = pathnameSplit.slice(0, keyIndex + 1).join("/");
router.push("/" + combinedPath);
};
const getPageIcon = () => {
if (pathname.includes("dashboard")) return <DashboardIcon size={40} />;
if (pathname.includes("article")) return <ArticleIcon size={40} />;
if (pathname.includes("master-category")) return <MasterCategoryIcon size={40} />;
if (pathname.includes("magazine")) return <MagazineIcon size={40} />;
if (pathname.includes("static-page")) return <StaticPageIcon size={40} />;
if (pathname.includes("master-user")) return <MasterUsersIcon size={40} />;
if (pathname.includes("master-role")) return <MasterRoleIcon size={40} />;
return null;
};
if (!mounted) {
return (
<div className="flex items-center space-x-6">
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse"></div>
<div className="flex flex-col space-y-2">
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse"></div>
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse"></div>
</div>
</div>
);
}
return (
<motion.div
className="flex items-center space-x-6"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
{/* Page Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="flex-shrink-0"
>
{getPageIcon()}
</motion.div>
{/* Page Title and Breadcrumbs */}
<div className="flex flex-col space-y-2">
<motion.h1
className="text-2xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
>
{pathnameTransformed[pathnameTransformed.length - 1]}
</motion.h1>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}
>
<Breadcrumb>
<BreadcrumbList className="flex items-center space-x-2">
{pathnameTransformed
?.filter((item) => item !== "Admin")
.map((item, index, array) => (
<React.Fragment key={pathnameSplit[index]}>
<BreadcrumbItem>
<BreadcrumbLink
onClick={() => handleAction(pathnameSplit[index])}
className={`text-sm transition-all duration-200 hover:text-blue-600 ${
pathnameSplit[index] === currentPage
? "font-semibold text-blue-600"
: "text-slate-500 hover:text-slate-700"
}`}
>
{item}
</BreadcrumbLink>
</BreadcrumbItem>
{index < array.length - 1 && (
<BreadcrumbSeparator className="text-slate-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</BreadcrumbSeparator>
)}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</motion.div>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,104 @@
"use client";
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ChunkErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
// Check if it's a chunk loading error
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
return { hasError: true, error };
}
return { hasError: false };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Chunk loading error:', error, errorInfo);
// If it's a chunk loading error, try to reload the page
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
this.setState({ hasError: true, error });
}
}
handleRetry = () => {
// Clear the error state and reload the page
this.setState({ hasError: false, error: undefined });
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="text-center p-8 max-w-md">
<div className="mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<RefreshCw className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Chunk Loading Error
</h2>
<p className="text-gray-600 mb-6">
There was an issue loading a part of the application. This usually happens when the application has been updated.
</p>
</div>
<div className="space-y-3">
<Button
onClick={this.handleRetry}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reload Application
</Button>
<Button
onClick={() => window.location.href = '/'}
variant="outline"
className="w-full"
>
Go to Homepage
</Button>
</div>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Error Details (Development)
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{this.state.error.message}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ChunkErrorBoundary;

View File

@ -0,0 +1,36 @@
import React from "react";
interface CircularProgressProps {
size?: number;
strokeWidth?: number;
value: number; // 0 to 100
className?: string;
}
export function CircularProgress({ size = 48, strokeWidth = 4, value, className }: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
return (
<svg width={size} height={size} className={className} viewBox={`0 0 ${size} ${size}`}>
<circle className="text-gray-200" stroke="currentColor" strokeWidth={strokeWidth} fill="transparent" r={radius} cx={size / 2} cy={size / 2} />
<circle
className="text-primary"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="transparent"
r={radius}
cx={size / 2}
cy={size / 2}
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 0.35s" }}
/>
<text x="50%" y="50%" dy=".3em" textAnchor="middle" className="text-xs fill-primary font-medium">
{Math.round(value)}%
</text>
</svg>
);
}

View File

@ -0,0 +1,54 @@
import React from "react";
type CircularProgressProps = {
value: number; // antara 0 - 100
size?: number; // diameter lingkaran (px)
strokeWidth?: number;
color?: string;
bgColor?: string;
label?: string;
};
export const CustomCircularProgress = ({
value,
size = 80,
strokeWidth = 8,
color = "#f59e0b", // shadcn's warning color
bgColor = "#e5e7eb", // gray-200
label,
}: CircularProgressProps) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = Math.min(Math.max(value, 0), 100); // jaga antara 0 - 100
const offset = circumference - (progress / 100) * circumference;
return (
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke={bgColor}
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke={color}
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
r={radius}
cx={size / 2}
cy={size / 2}
style={{ transition: "stroke-dashoffset 0.35s" }}
/>
</svg>
<span className="absolute text-sm font-semibold text-gray-800 dark:text-white">
{label ?? `${Math.round(progress)}%`}
</span>
</div>
);
};

View File

@ -0,0 +1,125 @@
"use client";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useEffect, useState } from "react";
export default function CustomPagination(props: {
totalPage: number;
onPageChange: (data: number) => void;
}) {
const { totalPage, onPageChange } = props;
const [page, setPage] = useState(1);
useEffect(() => {
onPageChange(page);
}, [page]);
const renderPageNumbers = () => {
const pageNumbers = [];
const halfWindow = Math.floor(5 / 2);
let startPage = Math.max(2, page - halfWindow);
let endPage = Math.min(totalPage - 1, page + halfWindow);
if (endPage - startPage + 1 < 5) {
if (page <= halfWindow) {
endPage = Math.min(
totalPage - 1,
endPage + (5 - (endPage - startPage + 1))
);
} else if (page + halfWindow >= totalPage) {
startPage = Math.max(2, startPage - (5 - (endPage - startPage + 1)));
}
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(
<PaginationItem key={i} onClick={() => setPage(i)}>
<PaginationLink className="cursor-pointer" isActive={page === i}>
{i}
</PaginationLink>
</PaginationItem>
);
}
return pageNumbers;
};
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
className="cursor-pointer"
onClick={() => (page > 10 ? setPage(page - 10) : "")}
>
{/* <DoubleArrowLeftIcon /> */}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
className="cursor-pointer"
onClick={() => (page > 1 ? setPage(page - 1) : "")}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
className="cursor-pointer"
onClick={() => setPage(1)}
isActive={page === 1}
>
{1}
</PaginationLink>
</PaginationItem>
{page > 4 && (
<PaginationItem>
<PaginationEllipsis
className="cursor-pointer"
onClick={() => setPage(page - 1)}
/>
</PaginationItem>
)}
{renderPageNumbers()}
{page < totalPage - 3 && (
<PaginationItem>
<PaginationEllipsis
className="cursor-pointer"
onClick={() => setPage(page + 1)}
/>
</PaginationItem>
)}
{totalPage > 1 && (
<PaginationItem>
<PaginationLink
className="cursor-pointer"
onClick={() => setPage(totalPage)}
isActive={page === totalPage}
>
{totalPage}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
className="cursor-pointer"
onClick={() => (page < totalPage ? setPage(page + 1) : "")}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={() => (page < totalPage - 10 ? setPage(page + 10) : "")}
>
{/* <DoubleArrowRightIcon /> */}
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

View File

@ -0,0 +1,58 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react';
interface SidebarContextType {
isOpen: boolean;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(() => {
if (typeof window !== 'undefined') {
const storedValue = localStorage.getItem('sidebarOpen');
return storedValue ? JSON.parse(storedValue) : false;
}
});
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
useEffect(() => {
localStorage.setItem('sidebarOpen', JSON.stringify(isOpen));
}, [isOpen]);
useEffect(() => {
const handleResize = () => {
setIsOpen(window.innerWidth > 768); // Ganti 768 dengan lebar yang sesuai dengan breakpoint Anda
};
handleResize(); // Pastikan untuk memanggil fungsi handleResize saat komponen dimuat
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<SidebarContext.Provider value={{ isOpen, toggleSidebar }}>
{children}
</SidebarContext.Provider>
);
};
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};
export const useSidebarContext = () => {
return useContext(SidebarContext);
};

View File

@ -0,0 +1,67 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Get theme from localStorage or default to 'light'
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
setThemeState(savedTheme);
} else {
// Check system preference
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
setThemeState(systemTheme);
}
}, []);
useEffect(() => {
if (!mounted) return;
// Update document class and localStorage
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme, mounted]);
const toggleTheme = () => {
setThemeState(prev => prev === 'light' ? 'dark' : 'light');
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
// Prevent hydration mismatch
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@ -0,0 +1,158 @@
"use client";
import { getStatisticMonthly } from "@/service/article";
import React, { useEffect, useState } from "react";
import { SafeReactApexChart } from "@/utils/dynamic-import";
type WeekData = {
week: number;
days: number[];
total: number;
};
type RemainingDays = {
days: number[];
total: number;
};
function processMonthlyData(count: number[]): {
weeks: WeekData[];
remaining_days: RemainingDays;
} {
const weeks: WeekData[] = [];
let weekIndex = 1;
for (let i = 0; i < count.length; i += 7) {
const weekData = count.slice(i, i + 7);
weeks.push({
week: weekIndex,
days: weekData,
total: weekData.reduce((sum, day) => sum + day, 0),
});
weekIndex++;
}
const remainingDays: RemainingDays = {
days: count.length % 7 === 0 ? [] : count.slice(-count.length % 7),
total: count.slice(-count.length % 7).reduce((sum, day) => sum + day, 0),
};
return {
weeks,
remaining_days: remainingDays,
};
}
const ApexChartColumn = (props: {
type: string;
date: string;
view: string[];
}) => {
const { date, type, view } = props;
const [categories, setCategories] = useState<string[]>([]);
const [series, setSeries] = useState<{ name: string; data: number[] }[]>([]);
const [seriesComment, setSeriesComment] = useState<number[]>([]);
const [seriesView, setSeriesView] = useState<number[]>([]);
const [seriesShare, setSeriesShare] = useState<number[]>([]);
useEffect(() => {
initFetch();
}, [date, type, view]);
const initFetch = async () => {
const splitDate = date.split(" ");
const res = await getStatisticMonthly(splitDate[1]);
const data = res?.data?.data;
const getDatas = data?.find(
(a: any) =>
a.month == Number(splitDate[0]) && a.year === Number(splitDate[1])
);
if (getDatas) {
const temp1 = processMonthlyData(getDatas?.comment);
const temp2 = processMonthlyData(getDatas?.view);
const temp3 = processMonthlyData(getDatas?.share);
if (type == "weekly") {
setSeriesComment(
temp1.weeks.map((list) => {
return list.total;
})
);
setSeriesView(
temp2.weeks.map((list) => {
return list.total;
})
);
setSeriesShare(
temp3.weeks.map((list) => {
return list.total;
})
);
} else {
setSeriesComment(getDatas.comment);
setSeriesView(getDatas.view);
setSeriesShare(getDatas.share);
}
if (type === "weekly") {
const category = [];
for (let i = 1; i <= temp1.weeks.length; i++) {
category.push(`Week ${i}`);
}
setCategories(category);
}
} else {
setSeriesComment([]);
}
};
useEffect(() => {
const temp = [
{
name: "Comment",
data: view.includes("comment") ? seriesComment : [],
},
{
name: "View",
data: view.includes("view") ? seriesView : [],
},
{
name: "Share",
data: view.includes("share") ? seriesShare : [],
},
];
console.log("temp", temp);
setSeries(temp);
}, [view, seriesShare, seriesView, seriesComment]);
return (
<div className="h-full">
<div id="chart" className="h-full">
<SafeReactApexChart
options={{
chart: {
height: "100%",
type: "area",
},
stroke: {
curve: "smooth",
},
dataLabels: {
enabled: false,
},
xaxis: {
categories: type == "weekly" ? categories : [],
},
}}
series={series}
type="area"
height="100%"
/>
</div>
<div id="html-dist"></div>
</div>
);
};
export default ApexChartColumn;

View File

@ -0,0 +1,404 @@
"use client";
import {
DashboardCommentIcon,
DashboardConnectIcon,
DashboardShareIcon,
DashboardSpeecIcon,
DashboardUserIcon,
} from "@/components/icons/dashboard-icon";
import Cookies from "js-cookie";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Article } from "@/types/globals";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
getListArticle,
getStatisticSummary,
getTopArticles,
getUserLevelDataStat,
} from "@/service/article";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import {
convertDateFormat,
convertDateFormatNoTime,
textEllipsis,
} from "@/utils/global";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Calendar } from "@/components/ui/calendar";
import ApexChartColumn from "@/components/main/dashboard/chart/column-chart";
import CustomPagination from "@/components/layout/custom-pagination";
import { motion } from "framer-motion";
type ArticleData = Article & {
no: number;
createdAt: string;
};
interface TopPages {
id: number;
no: number;
title: string;
viewCount: number;
}
interface PostCount {
userLevelId: number;
no: number;
userLevelName: string;
totalArticle: number;
}
export default function DashboardContainer() {
const username = Cookies.get("username");
const fullname = Cookies.get("ufne");
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [topPagesTotalPage, setTopPagesTotalPage] = useState(1);
const [article, setArticle] = useState<ArticleData[]>([]);
// const [analyticsView, setAnalyticView] = useState<string[]>(["comment", "view", "share"]);
// const [startDateValue, setStartDateValue] = useState(parseDate(convertDateFormatNoTimeV2(new Date())));
// const [postContentDate, setPostContentDate] = useState({
// startDate: parseDate(convertDateFormatNoTimeV2(new Date(new Date().setDate(new Date().getDate() - 7)))),
// endDate: parseDate(convertDateFormatNoTimeV2(new Date())),
// });
const [startDateValue, setStartDateValue] = useState(new Date());
const [analyticsView, setAnalyticView] = useState<string[]>([]);
const options = [
{ label: "Comment", value: "comment" },
{ label: "View", value: "view" },
{ label: "Share", value: "share" },
];
const handleChange = (value: string, checked: boolean) => {
if (checked) {
setAnalyticView([...analyticsView, value]);
} else {
setAnalyticView(analyticsView.filter((v) => v !== value));
}
};
const [postContentDate, setPostContentDate] = useState({
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
endDate: new Date(),
});
const [typeDate, setTypeDate] = useState("monthly");
const [summary, setSummary] = useState<any>();
const [topPages, setTopPages] = useState<TopPages[]>([]);
const [postCount, setPostCount] = useState<PostCount[]>([]);
useEffect(() => {
fetchSummary();
}, []);
useEffect(() => {
initState();
}, [page]);
async function initState() {
const req = {
limit: "5",
page: page,
search: "",
};
const res = await getListArticle(req);
setArticle(res.data?.data);
setTotalPage(res?.data?.meta?.totalPage);
}
async function fetchSummary() {
const res = await getStatisticSummary();
setSummary(res?.data?.data);
}
useEffect(() => {
fetchTopPages();
}, [page]);
async function fetchTopPages() {
const req = {
limit: "10",
page: page,
search: "",
};
const res = await getTopArticles(req);
setTopPages(getTableNumber(10, res.data?.data));
setTopPagesTotalPage(res?.data?.meta?.totalPage);
}
useEffect(() => {
fetchPostCount();
}, [postContentDate]);
async function fetchPostCount() {
const getDate = (data: any) => {
return `${data.year}-${data.month < 10 ? `0${data.month}` : data.month}-${
data.day < 10 ? `0${data.day}` : data.day
}`;
};
const res = await getUserLevelDataStat(
getDate(postContentDate.startDate),
getDate(postContentDate.endDate)
);
setPostCount(getTableNumber(10, res?.data?.data));
}
const getTableNumber = (limit: number, data: any) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
return newData;
}
};
const getMonthYear = (date: any) => {
return date.month + " " + date.year;
};
const getMonthYearName = (date: any) => {
const newDate = new Date(date);
const months = [
"Januari",
"Februari",
"Maret",
"April",
"Mei",
"Juni",
"Juli",
"Agustus",
"September",
"Oktober",
"November",
"Desember",
];
const year = newDate.getFullYear();
const month = months[newDate.getMonth()];
return month + " " + year;
};
return (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-6">
{/* User Profile Card */}
<motion.div
className="col-span-1 md:col-span-2 bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex justify-between items-start">
<div className="space-y-2">
<h3 className="text-xl font-bold text-slate-800">{fullname}</h3>
<p className="text-slate-600">{username}</p>
<div className="flex space-x-6 pt-2">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">
{summary?.totalToday}
</p>
<p className="text-sm text-slate-500">Today</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">
{summary?.totalThisWeek}
</p>
<p className="text-sm text-slate-500">This Week</p>
</div>
</div>
</div>
<div className="p-3 bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl">
<DashboardUserIcon size={60} />
</div>
</div>
</motion.div>
{/* Total Posts */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl">
<DashboardSpeecIcon />
</div>
<div>
<p className="text-3xl font-bold text-slate-800">
{summary?.totalAll}
</p>
<p className="text-sm text-slate-500">Total Posts</p>
</div>
</div>
</motion.div>
{/* Total Views */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl">
<DashboardConnectIcon />
</div>
<div>
<p className="text-3xl font-bold text-slate-800">
{summary?.totalViews}
</p>
<p className="text-sm text-slate-500">Total Views</p>
</div>
</div>
</motion.div>
{/* Total Shares */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
<DashboardShareIcon />
</div>
<div>
<p className="text-3xl font-bold text-slate-800">
{summary?.totalShares}
</p>
<p className="text-sm text-slate-500">Total Shares</p>
</div>
</div>
</motion.div>
{/* Total Comments */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl">
<DashboardCommentIcon size={40} />
</div>
<div>
<p className="text-3xl font-bold text-slate-800">
{summary?.totalComments}
</p>
<p className="text-sm text-slate-500">Total Comments</p>
</div>
</div>
</motion.div>
</div>
{/* Content Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Analytics Chart */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-slate-800">
Analytics Overview
</h3>
<div className="flex space-x-4">
{options.map((option) => (
<label
key={option.value}
className="flex items-center space-x-2"
>
<Checkbox
checked={analyticsView.includes(option.value)}
onCheckedChange={(checked) =>
handleChange(option.value, checked as boolean)
}
/>
<span className="text-sm text-slate-600">{option.label}</span>
</label>
))}
</div>
</div>
<div className="h-80">
<ApexChartColumn
type="monthly"
date={`${new Date().getMonth() + 1} ${new Date().getFullYear()}`}
view={analyticsView}
/>
</div>
</motion.div>
{/* Recent Articles */}
<motion.div
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-slate-800">
Recent Articles
</h3>
{/* <Link href="/admin/article/create">
<Button className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg">
Create Article
</Button>
</Link> */}
</div>
<div className="space-y-4 max-h-96 overflow-y-auto scrollbar-thin">
{article?.map((list: any) => (
<motion.div
key={list?.id}
className="flex space-x-4 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300 }}
>
<Image
alt="thumbnail"
src={list?.thumbnailUrl || `/no-image.jpg`}
width={80}
height={80}
className="h-20 w-20 object-cover rounded-lg shadow-sm flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-slate-800 line-clamp-2 mb-1">
{list?.title}
</h4>
<p className="text-sm text-slate-500">
{convertDateFormat(list?.createdAt)}
</p>
</div>
</motion.div>
))}
</div>
<div className="mt-6 flex justify-center">
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</motion.div>
</div>
</div>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import { useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { close, error, loading } from "@/config/swal";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import DOMPurify from "dompurify";
import { Card } from "@/components/ui/card";
import { createCustomStaticPage } from "@/service/static-page-service";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
const formSchema = z.object({
slug: z.string().min(2, {
message: "Slug must be at least 2 characters.",
}),
title: z.string().min(2, {
message: "Title must be at least 2 characters.",
}),
description: z.string().min(2, {
message: "Main Keyword must be at least 2 characters.",
}),
htmlBody: z.string().min(2, {
message: "Main Keyword must be at least 2 characters.",
}),
});
export default function StaticPageBuilder() {
const MySwal = withReactContent(Swal);
const router = useRouter();
const formOptions = {
resolver: zodResolver(formSchema),
};
type UserSettingSchema = z.infer<typeof formSchema>;
const {
control,
handleSubmit,
formState: { errors },
watch,
} = useForm<UserSettingSchema>(formOptions);
const content = watch("htmlBody");
const generatedPage = useCallback(() => {
const sanitizedContent = DOMPurify.sanitize(content);
const textArea = document.createElement("textarea");
textArea.innerHTML = sanitizedContent;
return (
<Card className="rounded-md border p-4">
{/* <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} /> */}
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
</Card>
);
}, [content]);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const request = {
title: values.title,
slug: values.slug,
description: values.description,
htmlBody: values.htmlBody,
};
loading();
const res = await createCustomStaticPage(request);
if (res?.error) {
error(res.message);
return false;
}
close();
successSubmit("/admin/static-page");
};
function successSubmit(redirect: any) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
// const title = watch("title");
// useEffect(() => {
// if (getValues("title")) {
// setValue("slug", createSlug(getValues("title")));
// }
// }, [title]);
return (
<form
className="flex flex-col gap-3 px-4"
onSubmit={handleSubmit(onSubmit)}
>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-1">
<Label htmlFor="title">Title</Label>
<Input
type="text"
id="title"
placeholder="Title"
value={value ?? ""}
onChange={onChange}
/>
</div>
)}
/>
{errors.title?.message && (
<p className="text-red-400 text-sm">{errors.title?.message}</p>
)}
<Controller
control={control}
name="slug"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-1">
<Label htmlFor="slug">Slug</Label>
<Input
type="text"
id="slug"
placeholder="Slug"
value={value ?? ""}
onChange={onChange}
/>
</div>
)}
/>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<div className="w-full space-y-1">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Description"
value={value}
onChange={onChange}
/>
</div>
)}
/>
{errors.description?.message && (
<p className="text-red-400 text-sm">{errors.description?.message}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="flex flex-col gap-2">
Editor
<Controller
control={control}
name="htmlBody"
render={({ field: { onChange, value } }) => (
// <Textarea
// variant="bordered"
// label=""
// labelPlacement="outside"
// placeholder=""
// className="max-h-[80vh]"
// classNames={{
// mainWrapper: "h-[80vh] overflow-hidden",
// innerWrapper: "h-[80vh] overflow-hidden",
// input: "min-h-full",
// inputWrapper: "h-full",
// }}
// value={value}
// onChange={onChange}
// disableAutosize={false}
// />
<CustomEditor onChange={onChange} initialData={value} />
)}
/>
</div>
<div className="px-4 flex flex-col gap-2">
Preview
{generatedPage()}
</div>
</div>
<div className="flex justify-end w-full">
<Button type="submit" color="primary" className="w-fit">
Save
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,606 @@
"use client";
import {
CloudUploadIcon,
CreateIconIon,
DeleteIcon,
DotsYIcon,
SearchIcon,
TimesIcon,
} from "@/components/icons";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { close, error, loading, success } from "@/config/swal";
import { Article } from "@/types/globals";
import Link from "next/link";
import { Fragment, Key, useCallback, useEffect, useState } from "react";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { useDropzone } from "react-dropzone";
import Image from "next/image";
import { Switch } from "@/components/ui/switch";
import useDisclosure from "@/components/useDisclosure";
import {
createMediaFileAdvertise,
deleteAdvertise,
editAdvertise,
editAdvertiseIsActive,
getAdvertise,
getAdvertiseById,
} from "@/service/advertisement";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "@/components/layout/custom-pagination";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
const columns = [
{ name: "No", uid: "no" },
{ name: "Judul", uid: "title" },
{ name: "Deskripsi", uid: "description" },
{ name: "Penempatan", uid: "placement" },
{ name: "Link", uid: "redirectLink" },
{ name: "Aktif", uid: "isActive" },
{ name: "Aksi", uid: "actions" },
];
const createArticleSchema = z.object({
id: z.string().optional(),
title: z.string().min(2, {
message: "Judul harus diisi",
}),
url: z.string().min(1, {
message: "Url harus diisi",
}),
description: z.string().min(2, {
message: "Deskripsi harus diisi",
}),
file: z.string().optional(),
});
export default function AdvertiseTable(props: { triggerRefresh: boolean }) {
const MySwal = withReactContent(Swal);
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [article, setArticle] = useState<any[]>([]);
const [showData, setShowData] = useState("10");
const [search, setSearch] = useState("");
const [placement, setPlacement] = useState("banner");
const [refresh, setRefresh] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", url: "", file: "" },
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
maxFiles: 1,
accept: {
"image/*": [],
},
});
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
initState();
}, [page, showData, props.triggerRefresh, refresh]);
const handleRemoveFile = (file: File) => {
const uploadedFiles = files;
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
setFiles([...filtered]);
};
async function initState() {
const req = {
limit: showData,
page: page,
search: search,
sort: "desc",
sortBy: "created_at",
};
const res = await getAdvertise(req);
getTableNumber(parseInt(showData), res.data?.data);
setTotalPage(res?.data?.meta?.totalPage);
}
const getTableNumber = (limit: number, data: Article[]) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
setArticle(newData);
}
};
async function doDelete(id: any) {
loading();
const resDelete = await deleteAdvertise(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
success("Berhasil Hapus");
setRefresh(!refresh);
}
const handleDelete = (id: any) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
loading();
const formData = {
id: Number(values.id),
title: values.title,
description: values.description,
placement: placement,
redirectLink: values.url,
};
const res = await editAdvertise(formData);
if (res?.error) {
error(res?.message);
return false;
}
if (files.length > 0) {
const formFiles = new FormData();
formFiles.append("file", files[0]);
const resFile = await createMediaFileAdvertise(
Number(values.id),
formFiles
);
}
close();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
setRefresh(!refresh);
}
});
};
const openModal = async (id: number) => {
const res = await getAdvertiseById(Number(id));
const data = res?.data?.data;
setValue("id", String(data?.id));
setValue("title", data?.title);
setValue("description", data?.description);
setValue("url", data?.redirectLink);
setPlacement(data?.placement);
// setValue("file", data?.thumbnailUrl);
onOpen();
};
const handleAdvertise = async (e: boolean, id: number) => {
const res = await editAdvertiseIsActive({ id, isActive: e });
if (res?.error) {
error(res?.message);
return false;
}
setRefresh(!refresh);
};
const renderCell = useCallback(
(advertise: any, columnKey: Key) => {
const cellValue = advertise[columnKey as keyof any];
switch (columnKey) {
case "redirectLink":
return cellValue.includes("https") ? (
<Link
href={cellValue}
target="_blank"
className="text-primary hover:underline cursor-pointer"
>
{cellValue}
</Link>
) : (
<p> {cellValue}</p>
);
case "placement":
return <p className="capitalize">{cellValue}</p>;
case "isActive":
return (
<div className="flex flex-row gap-2 items-center">
<Switch
checked={advertise?.isPublish}
onCheckedChange={(e) => handleAdvertise(e, advertise?.id)}
/>
{advertise?.isPublish ? "Ya" : "Tidak"}
</div>
);
case "actions":
return (
<div className="relative flex justify-start items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground"
>
<DotsYIcon className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
{/*
<DropdownMenuItem asChild>
<Link href={`/admin/advertise/detail/${article.id}`}>
<EyeIconMdi className="inline mr-2 mb-1" />
Detail
</Link>
</DropdownMenuItem>
*/}
<DropdownMenuItem onClick={() => openModal(advertise.id)}>
<CreateIconIon className="inline mr-2 mb-1" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(advertise.id)}>
<DeleteIcon
color="red"
size={18}
className="inline ml-1 mr-2 mb-1"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
},
[article, props.triggerRefresh, refresh]
);
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
};
async function doneTyping() {
initState();
}
return (
<>
<div className="py-3">
<div className="flex flex-col items-start rounded-2xl gap-3">
<div className="flex flex-col md:flex-row gap-3 w-full">
<div className="flex flex-col gap-1 w-full lg:w-1/3">
<p className="font-semibold text-sm">Pencarian</p>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
<Input
aria-label="Search"
type="text"
className="pl-10 text-sm bg-muted"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
<p className="font-semibold text-sm">Data</p>
<Select
value={showData}
onValueChange={(value) =>
value === "" ? "" : setShowData(value)
}
>
<SelectTrigger className="w-full border">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Table className="rounded-3xl min-h-[50px] bg-white dark:bg-black border text-black dark:text-white">
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.uid}
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
>
{column.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{article.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-4"
>
No data to display.
</TableCell>
</TableRow>
) : (
article.map((item: any) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.uid}>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Advertise</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1">
<p className="text-sm">Judul</p>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<Input
type="text"
id="title"
value={value}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm">{errors.title?.message}</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<Textarea
id="description"
value={value}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
/>
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm">
{errors.description?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Link</p>
<Controller
control={control}
name="url"
render={({ field: { onChange, value } }) => (
<Input
type="text"
id="url"
value={value}
onChange={onChange}
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
/>
)}
/>
{errors?.url && (
<p className="text-red-400 text-sm">{errors.url?.message}</p>
)}
</div>
<p className="text-sm mt-3">Penempatan</p>
<RadioGroup
value={placement}
onValueChange={setPlacement}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="banner" id="banner" />
<label htmlFor="banner" className="text-sm">
Banner
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="jumbotron" id="jumbotron" />
<label htmlFor="jumbotron" className="text-sm">
Jumbotron
</label>
</div>
</RadioGroup>
<Controller
control={control}
name="file"
render={({ field: { value } }) => (
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Thumbnail</p>
{files.length < 1 && value === "" && (
<Fragment>
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border border-gray-300 rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon />
<h4 className="text-2xl font-medium mb-1 mt-3 text-gray-700">
Tarik file disini atau klik untuk upload.
</h4>
<div className="text-xs text-gray-500">
( Upload file dengan format .jpg, .jpeg, atau .png.
Ukuran maksimal 100mb.)
</div>
</div>
</div>
</Fragment>
)}
{value !== "" && (
<div className="flex flex-row gap-2">
<Image
src={String(value)}
className="w-[30%]"
alt="thumbnail"
width={480}
height={480}
/>
<Button
type="button"
onClick={() => setValue("file", "")}
variant="outline"
size="sm"
>
<TimesIcon />
</Button>
</div>
)}
{files.length > 0 && (
<div className="flex flex-row gap-2">
<img
src={URL.createObjectURL(files[0])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
type="button"
onClick={() => handleRemoveFile(files[0])}
variant="outline"
size="sm"
>
<TimesIcon />
</Button>
</div>
)}
</div>
)}
/>
<DialogFooter className="mt-4 flex justify-end gap-2">
<Button type="submit">Simpan</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Tutup
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,517 @@
"use client";
import {
BannerIcon,
CopyIcon,
CreateIconIon,
DeleteIcon,
DotsYIcon,
EyeIconMdi,
SearchIcon,
} from "@/components/icons";
import { close, error, loading, success, successToast } from "@/config/swal";
import { Article } from "@/types/globals";
import { convertDateFormat, formatDate } from "@/utils/global";
import Link from "next/link";
import { Key, useCallback, useEffect, useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import Cookies from "js-cookie";
import {
deleteArticle,
getArticleByCategory,
getArticlePagination,
updateIsBannerArticle,
} from "@/service/article";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "../layout/custom-pagination";
import DatePicker from "react-datepicker";
const columns = [
{ name: "No", uid: "no" },
{ name: "Judul", uid: "title" },
{ name: "Banner", uid: "isBanner" },
{ name: "Kategori", uid: "category" },
{ name: "Tanggal Unggah", uid: "createdAt" },
{ name: "Kreator", uid: "customCreatorName" },
{ name: "Status", uid: "isPublish" },
{ name: "Aksi", uid: "actions" },
];
const columnsOtherRole = [
{ name: "No", uid: "no" },
{ name: "Judul", uid: "title" },
{ name: "Source", uid: "source" },
{ name: "Kategori", uid: "category" },
{ name: "Tanggal Unggah", uid: "createdAt" },
{ name: "Kreator", uid: "customCreatorName" },
{ name: "Status", uid: "isPublish" },
{ name: "Aksi", uid: "actions" },
];
// interface Category {
// id: number;
// title: string;
// }
export default function ArticleTable() {
const MySwal = withReactContent(Swal);
const username = Cookies.get("username");
const userId = Cookies.get("uie");
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [article, setArticle] = useState<any[]>([]);
const [showData, setShowData] = useState("10");
const [search, setSearch] = useState("");
const [categories, setCategories] = useState<any>([]);
const [selectedCategories, setSelectedCategories] = useState<any>("");
const [selectedCategoryId, setSelectedCategoryId] = useState<any>("");
const [selectedSource, setSelectedSource] = useState<any>("");
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [dateRange, setDateRange] = useState<any>({
startDate: null,
endDate: null,
});
useEffect(() => {
initState();
getCategories();
}, []);
async function getCategories() {
const res = await getArticleByCategory();
const data = res?.data?.data;
setCategories(data);
console.log("category", data);
}
useEffect(() => {
initState();
}, [
page,
showData,
search,
selectedCategoryId,
selectedSource,
dateRange,
selectedStatus,
]);
async function initState() {
loading();
const req = {
limit: showData,
page: page,
search: search,
category: selectedCategoryId || "",
source: selectedSource || "",
isPublish:
selectedStatus !== "" ? selectedStatus === "publish" : undefined,
startDate: formatDate(dateRange.startDate),
endDate: formatDate(dateRange.endDate),
sort: "desc",
sortBy: "created_at",
};
const res = await getArticlePagination(req);
let data = res.data?.data || [];
await getTableNumber(parseInt(showData), data);
setTotalPage(res?.data?.meta?.totalPage);
close();
}
// panggil ulang setiap state berubah
useEffect(() => {
initState();
}, [page, showData, search, selectedCategories]);
const getTableNumber = async (limit: number, data: Article[]) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
setArticle(newData);
} else {
setArticle([]);
}
};
async function doDelete(id: any) {
// loading();
const resDelete = await deleteArticle(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
success("Berhasil Hapus");
initState();
}
const handleDelete = (id: any) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
const handleBanner = async (id: number, status: boolean) => {
const res = await updateIsBannerArticle(id, status);
if (res?.error) {
error(res?.message);
return false;
}
initState();
};
const copyUrlArticle = async (id: number) => {
const url =
`${window.location.protocol}//${window.location.host}` +
"/detail/" +
`${id}`;
try {
await navigator.clipboard.writeText(url);
successToast("Success", "Article Copy to Clipboard");
setTimeout(() => {}, 1500);
} catch (err) {
("Failed to copy!");
}
};
const renderCell = useCallback(
(article: any, columnKey: Key) => {
const cellValue = article[columnKey as keyof any];
switch (columnKey) {
case "customCreatorName":
return (
<p>
{article.customCreatorName &&
article.customCreatorName.trim() !== ""
? article.customCreatorName
: article.createdByName}
</p>
);
case "isPublish":
return (
// <Chip
// className="capitalize "
// color={statusColorMap[article.status]}
// size="lg"
// variant="flat"
// >
// <div className="flex flex-row items-center gap-2 justify-center">
// {article.status}
// </div>
// </Chip>
<p>{article.isPublish ? "Publish" : "Draft"}</p>
);
case "isBanner":
return <p>{article.isBanner ? "Ya" : "Tidak"}</p>;
case "createdAt":
return <p>{convertDateFormat(article.createdAt)}</p>;
case "category":
return (
<p>
{article?.categories?.map((list: any) => list.title).join(", ") +
" "}
</p>
);
case "actions":
return (
<div className="relative flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<DotsYIcon className="h-5 w-5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuItem onClick={() => copyUrlArticle(article.id)}>
<CopyIcon className="mr-2 h-4 w-4" />
Copy Url Article
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/admin/article/detail/${article.id}`}
className="flex items-center"
>
<EyeIconMdi className="mr-2 h-4 w-4" />
Detail
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/admin/article/edit/${article.id}`}
className="flex items-center"
>
<CreateIconIon className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{username === "admin-mabes" && (
<DropdownMenuItem
onClick={() =>
handleBanner(article.id, !article.isBanner)
}
>
<BannerIcon className="mr-2 h-4 w-4" />
{article.isBanner
? "Hapus dari Banner"
: "Jadikan Banner"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleDelete(article.id)}>
<DeleteIcon className="mr-2 h-4 w-4 text-red-500" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
},
[article, page]
);
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
};
async function doneTyping() {
setPage(1);
initState();
}
return (
<>
<div className="py-3">
<div className="flex flex-col items-start rounded-2xl gap-3">
<div className="flex flex-col md:flex-row gap-3 w-full">
<div className="flex flex-col gap-1 w-full lg:w-1/3">
<p className="font-semibold text-sm">Pencarian</p>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground h-4 w-4 pointer-events-none" />
<Input
type="text"
placeholder="Cari..."
className="pl-9 text-sm bg-muted"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
<p className="font-semibold text-sm">Data</p>
<Select
value={showData}
onValueChange={(value) => setShowData(value)}
>
<SelectTrigger className="w-full text-sm border">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[230px]">
<p className="font-semibold text-sm">Kategori</p>
<Select
value={selectedCategoryId}
onValueChange={(value) => setSelectedCategoryId(value)} // simpan ID
>
<SelectTrigger className="w-full text-sm border">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
{categories
?.filter((category: any) => category.title != null)
.map((category: any) => (
<SelectItem
key={category.id}
value={category.id.toString()} // kirim ID, bukan title
>
{category.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[150px]">
<p className="font-semibold text-sm">Source</p>
<Select
value={selectedSource}
onValueChange={(value) => setSelectedSource(value)}
>
<SelectTrigger className="w-full text-sm border">
<SelectValue placeholder="Pilih Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">INTERNAL</SelectItem>
<SelectItem value="external">EXTERNAL</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[150px]">
<p className="font-semibold text-sm">Status</p>
<Select
value={selectedStatus}
onValueChange={(value) => setSelectedStatus(value)}
>
<SelectTrigger className="w-full text-sm border">
<SelectValue placeholder="Pilih Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="publish">PUBLISH</SelectItem>
<SelectItem value="draft">DRAFT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[240px]">
<p className="font-semibold text-sm">Tanggal</p>
<DatePicker
selectsRange
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={(update: [Date | null, Date | null]) => {
setDateRange({
startDate: update[0],
endDate: update[1],
});
}}
isClearable
dateFormat="dd/MM/yyyy"
className="z-50 w-full text-sm bg-transparent border border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
placeholderText="Pilih rentang tanggal"
/>
</div>
</div>
<div className="w-full overflow-x-hidden">
<div className="w-full mx-auto overflow-x-hidden">
<Table className="w-full table-fixed border text-sm">
<TableHeader>
<TableRow>
{(username === "admin-mabes"
? columns
: columnsOtherRole
).map((column) => (
<TableHead
key={column.uid}
className="truncate bg-white dark:bg-black text-black dark:text-white border-b text-md"
>
{column.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{article.length > 0 ? (
article.map((item: any) => (
<TableRow key={item.id}>
{(username === "admin-mabes"
? columns
: columnsOtherRole
).map((column) => (
<TableCell
key={column.uid}
className="truncate text-black dark:text-white max-w-[200px]"
>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-4"
>
No data to display.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,363 @@
"use client";
import {
CreateIconIon,
DeleteIcon,
DotsYIcon,
EyeIconMdi,
SearchIcon,
} from "@/components/icons";
import { close, error, loading, success } from "@/config/swal";
import { getArticleByCategory } from "@/service/article";
import { deleteMagazine, getListMagazine } from "@/service/magazine";
import { Article } from "@/types/globals";
import { convertDateFormat } from "@/utils/global";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
import { Key, useCallback, useEffect, useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "@/components/layout/custom-pagination";
import { Chip, ChipProps } from "@/components/ui/chip";
const columns = [
{ name: "No", uid: "no" },
{ name: "Judul", uid: "title" },
// { name: "Kategori", uid: "categoryName" },
{ name: "Tanggal Unggah", uid: "createdAt" },
{ name: "Kreator", uid: "createdByName" },
{ name: "Aksi", uid: "actions" },
];
type ArticleData = Article & {
no: number;
createdAt: string;
};
export default function MagazineTable() {
const MySwal = withReactContent(Swal);
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [article, setArticle] = useState<ArticleData[]>([]);
const [showData, setShowData] = useState("10");
const [search, setSearch] = useState("");
const [, setCategoies] = useState<any>([]);
const [startDateValue] = useState({
startDate: null,
endDate: null,
});
useEffect(() => {
initState();
}, [page, showData, startDateValue]);
useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const res = await getArticleByCategory();
const data = res?.data?.data;
console.log("datass", res?.data?.data);
setCategoies(data);
}
async function initState() {
const req = {
limit: showData,
page: page,
search: search,
startDate:
startDateValue.startDate === null ? "" : startDateValue.startDate,
endDate: startDateValue.endDate === null ? "" : startDateValue.endDate,
};
const res = await getListMagazine(req);
getTableNumber(parseInt(showData), res.data?.data);
console.log("res.data?.data magz", res.data);
setTotalPage(res?.data?.meta?.totalPage);
}
const getTableNumber = (limit: number, data: Article[]) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
console.log("daata", data);
setArticle(newData);
} else {
setArticle([]);
}
};
async function doDelete(id: any) {
loading();
const resDelete = await deleteMagazine(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
success("Berhasil Hapus");
initState();
}
const handleDelete = (id: any) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
const cellValue = article[columnKey as keyof ArticleData];
const statusColorMap: Record<string, ChipProps["color"]> = {
active: "primary",
cancel: "danger",
pending: "success",
};
switch (columnKey) {
case "status":
return (
<Chip
className="capitalize "
color={statusColorMap[article.status]}
size="lg"
variant="flat"
>
<div className="flex flex-row items-center gap-2 justify-center">
{cellValue}
</div>
</Chip>
);
case "createdAt":
return <p>{convertDateFormat(article.createdAt)}</p>;
case "actions":
return (
<div className="relative flex justify-start items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<DotsYIcon className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-black text-white border shadow-lg">
<DropdownMenuItem asChild>
<Link
href={`/admin/magazine/detail/${article.id}`}
className="flex items-center"
>
<EyeIconMdi className="inline mr-2 mb-1" />
Detail
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/admin/magazine/edit/${article.id}`}
className="flex items-center"
>
<CreateIconIon className="inline mr-2 mb-1" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(article.id)}
className="text-red-500"
>
<DeleteIcon
width={20}
height={16}
className="inline mr-2 mb-1"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
}, []);
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
};
async function doneTyping() {
initState();
}
return (
<>
<div className="py-3 w-full">
<div className="flex flex-col items-start rounded-2xl gap-3">
<div className="flex flex-col md:flex-row gap-3 w-full">
<div className="flex flex-col gap-1 w-full lg:w-1/3">
<p className="font-semibold text-sm">Pencarian</p>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search"
className="pl-10 text-sm bg-muted"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
<p className="font-semibold text-sm">Data</p>
<Select
onValueChange={(value) => setShowData(value)}
value={showData}
>
<SelectTrigger className="w-full border">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
{/* <div className="flex flex-col gap-1 w-[230px]">
<p className="font-semibold text-sm">Kategori</p>
<Select
label=""
variant="bordered"
labelPlacement="outside"
placeholder="Select"
selectionMode="multiple"
selectedKeys={[selectedCategories]}
className="w-full"
classNames={{ trigger: "border-1" }}
onChange={(e) => {
e.target.value === ""
? ""
: setSelectedCategories(e.target.value);
}}
>
{categories?.map((category: any) => (
<SelectItem key={category?.id} value={category?.id}>
{category?.title}
</SelectItem>
))}
</Select>
</div> */}
{/* <div className="flex flex-col gap-1 w-full lg:w-[240px]">
<p className="font-semibold text-sm">Tanggal</p>
<Datepicker
value={startDateValue}
displayFormat="DD/MM/YYYY"
onChange={(e: any) => setStartDateValue(e)}
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
/>
</div> */}
</div>
<div className="rounded-3xl border overflow-hidden w-full">
<Table>
<thead className="bg-white dark:bg-black text-black dark:text-white border-b">
<tr>
{columns.map((column) => (
<TableHead key={column.uid} className="text-md">
{column.name}
</TableHead>
))}
</tr>
</thead>
<TableBody>
{article.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-4"
>
No data to display.
</TableCell>
</TableRow>
) : (
article.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.uid}>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,712 @@
"use client";
import {
CloudUploadIcon,
CreateIconIon,
DeleteIcon,
DotsYIcon,
EyeIconMdi,
SearchIcon,
} from "@/components/icons";
import { Article } from "@/types/globals";
import { convertDateFormat } from "@/utils/global";
import { Key, useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { useDropzone } from "react-dropzone";
import { close, error, loading, success } from "@/config/swal";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import useDisclosure from "@/components/useDisclosure";
import { getArticleByCategory, getCategoryPagination } from "@/service/article";
import {
deleteCategory,
getCategoryById,
updateCategory,
uploadCategoryThumbnail,
} from "@/service/master-categories";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "@/components/layout/custom-pagination";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
const columns = [
{ name: "No", uid: "no" },
{ name: "Kategori", uid: "title" },
{ name: "Deskripsi", uid: "description" },
{ name: "Tag Terkait", uid: "tags" },
{ name: "Dibuat ", uid: "createdAt" },
{ name: "Aksi", uid: "actions" },
];
interface CategoryType {
id: number;
label: string;
value: number;
}
type ArticleData = Article & {
no: number;
createdAt: string;
tags: string[];
};
// const categorySchema = z.object({
// id: z.number(),
// label: z.string(),
// value: z.number(),
// });
const createArticleSchema = z.object({
id: z.string().min(1, {
message: "Id harus valid",
}),
title: z.string().min(2, {
message: "Judul harus diisi",
}),
description: z.string().min(2, {
message: "Deskripsi harus diisi",
}),
tags: z.array(z.string()),
file: z.string(),
});
export default function CategoriesTable(props: { triggerRefresh: boolean }) {
const MySwal = withReactContent(Swal);
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const animatedComponents = makeAnimated();
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [categories, setCategories] = useState<ArticleData[]>([]);
const [showData, setShowData] = useState("10");
const [search, setSearch] = useState("");
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [isDetail, setIsDetail] = useState(false);
const [tag, setTag] = useState("");
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", category: [], tags: [] },
};
const [selectedParent, setSelectedParent] = useState<any>();
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
maxFiles: 1,
accept: {
"image/*": [],
},
});
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
getValues,
setError,
clearErrors,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
initState();
}, [page, showData, props.triggerRefresh]);
async function initState() {
const req = {
limit: showData,
page: page,
search: search,
};
const res = await getCategoryPagination(req);
getTableNumber(parseInt(showData), res.data?.data);
setTotalPage(res?.data?.meta?.totalPage);
}
const getTableNumber = (limit: number, data: Article[]) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
setCategories(newData);
} else {
setCategories([]);
}
};
async function doDelete(id: number) {
// loading();
const resDelete = await deleteCategory(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
success("Berhasil Hapus");
initState();
}
const handleDelete = (id: number) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
useEffect(() => {
fetchCategory();
}, []);
const fetchCategory = async () => {
const res = await getArticleByCategory();
if (res?.data?.data) {
setupCategory(res?.data?.data);
}
};
const setupCategory = (data: any) => {
const temp = [];
for (const element of data) {
temp.push({
id: element.id,
label: element.title,
value: element.id,
});
}
setListCategory(temp);
};
const openModal = async (id: number | string, detail: boolean) => {
setIsDetail(detail);
const res = await getCategoryById(Number(id));
const data = res?.data?.data;
setValue("id", String(data?.id));
setValue("title", data?.title);
setValue("description", data?.description);
setValue("tags", data?.tags);
setValue("file", data?.thumbnailUrl);
findParent(data?.parentId);
onOpen();
};
const findParent = (parent: number | undefined) => {
const finded = listCategory?.find((a: any) => a.id === parent);
if (finded) {
setSelectedParent(finded);
}
};
const renderCell = useCallback(
(category: ArticleData, columnKey: Key) => {
const cellValue = category[columnKey as keyof ArticleData];
// const statusColorMap: Record<string, ChipProps["color"]> = {
// active: "primary",
// cancel: "danger",
// pending: "success",
// };
// const findRelated = (parent: number | string) => {
// const filter = listCategory?.filter((a) => a.id == parent);
// return filter[0]?.label;
// };
switch (columnKey) {
case "tags":
return (
<div className="flex flex-row gap-1">
{category.tags
? category.tags.map((value) => value).join(", ")
: "-"}
</div>
);
case "createdAt":
return <p>{convertDateFormat(category.createdAt)}</p>;
case "actions":
return (
<div className="relative flex justify-star items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<DotsYIcon className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
<DropdownMenuItem
onClick={() => openModal(category.id, true)}
>
<EyeIconMdi className="inline mr-2 mb-1" />
Detail
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openModal(category.id, false)}
>
<CreateIconIon className="inline mr-2 mb-1" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(category.id)}>
<DeleteIcon
color="red"
size={20}
className="inline mr-3 mb-1"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
},
[listCategory]
);
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
};
async function doneTyping() {
initState();
}
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
loading();
const formData = {
id: Number(values.id),
title: values.title,
statusId: 1,
parentId: selectedParent ? selectedParent.id : 0,
tags: values.tags.join(","),
description: values.description,
};
const response = await updateCategory(values.id, formData);
if (response?.error) {
error(response.message);
return false;
}
if (files?.length > 0) {
const formFiles = new FormData();
formFiles.append("files", files[0]);
const resFile = await uploadCategoryThumbnail(values.id, formFiles);
if (resFile?.error) {
error(resFile.message);
return false;
}
}
setFiles([]);
close();
initState();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
}
});
};
const handleRemoveFile = (file: File) => {
const uploadedFiles = files;
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
setFiles([...filtered]);
};
return (
<>
<div className="py-3 w-full">
<div className="flex flex-col items-start rounded-2xl gap-3 w-full">
<div className="flex flex-col md:flex-row gap-3 w-full">
<div className="flex flex-col gap-1 w-full lg:w-1/3">
<p className="font-semibold text-sm">Pencarian</p>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-base pointer-events-none" />
<Input
type="text"
placeholder="Cari..."
aria-label="Search"
className="pl-10 text-sm bg-muted"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
<p className="font-semibold text-sm">Data</p>
<Select
value={showData}
onValueChange={(value) =>
value === "" ? "" : setShowData(value)
}
>
<SelectTrigger className="w-full border">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* <Table
aria-label="micro issue table"
className="rounded-3xl"
classNames={{
th: "bg-white dark:bg-black text-black dark:text-white border-b-1 text-md",
base: "bg-white dark:bg-black border",
wrapper: "min-h-[50px] bg-transpararent text-black dark:text-white ",
}}
>
<TableHeader columns={columns}>{(column) => <TableColumn key={column.uid}>{column.name}</TableColumn>}</TableHeader>
<TableBody items={categories} emptyContent={"No data to display."} loadingContent={<Spinner label="Loading..." />}>
{(item) => <TableRow key={item.id}>{(columnKey) => <TableCell>{renderCell(item, columnKey)}</TableCell>}</TableRow>}
</TableBody>
</Table> */}
<div className="rounded-3xl border bg-white dark:bg-black text-black dark:text-white w-full">
<Table className="min-h-[50px] w-full">
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.uid}
className="text-md border-b bg-white dark:bg-black text-black dark:text-white"
>
{column.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{categories.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-4"
>
No data to display.
</TableCell>
</TableRow>
) : (
categories.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.uid}>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Kategori Baru</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[70vh] pr-4">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1">
<p className="text-sm">Nama Kategori</p>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<Input
id="title"
type="text"
value={value}
onChange={onChange}
readOnly={isDetail}
className="rounded-lg border dark:border-gray-400"
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm">
{errors.title?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<Textarea
id="description"
value={value}
onChange={onChange}
readOnly={isDetail}
className="rounded-lg border dark:border-gray-400"
/>
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm">
{errors.description?.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Parent</p>
<ReactSelect
className="text-black z-50"
classNames={{
control: () =>
"rounded-lg bg-white border border-gray-200 dark:border-stone-500",
}}
classNamePrefix="select"
value={selectedParent}
isDisabled={isDetail}
onChange={setSelectedParent}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable
isSearchable
isMulti={false}
placeholder="Kategori..."
name="sub-module"
options={listCategory}
/>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Tag Terkait</p>
<Controller
control={control}
name="tags"
render={({ field: { value } }) => (
<div className="relative">
<Input
id="tags"
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (tag.trim() !== "") {
setValue("tags", [...value, tag.trim()]);
setTag("");
e.preventDefault();
}
}
}}
readOnly={isDetail}
className="rounded-lg border dark:border-gray-400 h-[45px]"
/>
<div className="absolute top-2 left-3 flex gap-1">
{value?.map((item, index) => (
<div
key={index}
className="bg-blue-100 text-blue-700 px-2 py-1 text-xs rounded flex items-center gap-1"
>
{item}
<button
type="button"
onClick={() => {
const filtered = value.filter(
(tag) => tag !== item
);
if (filtered.length === 0) {
setError("tags", {
type: "manual",
message: "Tags tidak boleh kosong",
});
} else {
clearErrors("tags");
setValue(
"tags",
filtered as [string, ...string[]]
);
}
}}
>
×
</button>
</div>
))}
</div>
</div>
)}
/>
</div>
{isDetail ? (
<img
src={getValues("file")}
className="w-[30%]"
alt="thumbnail"
width={480}
height={480}
/>
) : (
<Controller
control={control}
name="file"
render={({ field: { value } }) => (
<div className="flex flex-col gap-1">
<p className="text-sm mt-3">Thumbnail</p>
{files.length < 1 && value === "" && (
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon />
<h4 className="text-2xl font-medium mb-1 mt-3">
Tarik file disini atau klik untuk upload.
</h4>
<div className="text-xs text-muted-foreground">
( Upload file .jpg, .jpeg, .png. Maks 100mb )
</div>
</div>
</div>
)}
{value !== "" && (
<div className="flex gap-2">
<img
src={value}
className="w-[30%]"
alt="thumbnail"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setValue("file", "")}
>
×
</Button>
</div>
)}
{files.length > 0 && (
<div className="flex gap-2">
<Image
src={URL.createObjectURL(files[0])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleRemoveFile(files[0])}
>
×
</Button>
</div>
)}
</div>
)}
/>
)}
<DialogFooter className="gap-2 mt-4">
{!isDetail && <Button type="submit">Simpan</Button>}
<Button type="button" variant="outline" onClick={onClose}>
Tutup
</Button>
</DialogFooter>
</form>
</ScrollArea>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,237 @@
"use client";
import { CreateIconIon, DeleteIcon, DotsYIcon } from "@/components/icons";
import { close, error } from "@/config/swal";
import { deleteMasterUser, listMasterUsers } from "@/service/master-user";
import { MasterUser } from "@/types/globals";
import Link from "next/link";
import { Key, useCallback, useEffect, useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "../ui/button";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "../layout/custom-pagination";
const columns = [
{ name: "No", uid: "no" },
{ name: "Username", uid: "username" },
{ name: "Fullname", uid: "fullname" },
{ name: "Email", uid: "email" },
{ name: "Identity Type", uid: "identityType" },
{ name: "Identity Number", uid: "identityNumber" },
// { name: "Users", uid: "users" },
// { name: "Status", uid: "status" },
{ name: "Aksi", uid: "actions" },
];
export default function MasterUserTable() {
const MySwal = withReactContent(Swal);
const [user, setUser] = useState<MasterUser[]>([]);
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
useEffect(() => {
initState();
}, [page]);
async function initState() {
const res = await listMasterUsers({ page: page, limit: 10 });
getTableNumber(10, res?.data?.data);
setTotalPage(res?.data?.meta?.totalPage);
}
const getTableNumber = (limit: number, data?: any) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
setUser(newData);
}
};
async function doDelete(id: string) {
// loading();
const resDelete = await deleteMasterUser(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
successSubmit();
}
const handleDelete = (id: any) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
function successSubmit() {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
initState();
}
});
}
const renderCell = useCallback((user: MasterUser, columnKey: Key) => {
const cellValue = user[columnKey as keyof MasterUser];
// const statusColorMap: Record<string, ChipProps["color"]> = {
// active: "primary",
// cancel: "danger",
// pending: "success",
// };
switch (columnKey) {
case "id":
return <div>{user.id}</div>;
case "status":
return (
<div></div>
// <Chip
// className="capitalize "
// // color={statusColorMap[user.status]}
// size="lg"
// variant="flat"
// >
// <div className="flex flex-row items-center gap-2 justify-center">{cellValue}</div>
// </Chip>
);
case "actions":
return (
<div className="relative flex justify-start items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
>
<DotsYIcon className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem asChild>
<Link
href={`/admin/master-user/edit/${user.id}`}
className="flex items-center"
>
<CreateIconIon className="inline mr-2 mb-1" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(user.id)}
className="text-red-600"
>
<DeleteIcon
width={20}
height={16}
className="inline mr-2 mb-1"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
}, []);
return (
<>
<div className="mx-3 my-5">
<div className="flex flex-col items-center rounded-2xl">
<Table className="rounded-2xl text-black dark:text-white bg-white dark:bg-black min-h-[50px]">
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.uid}
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
>
{column.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{user.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No data to display.
</TableCell>
</TableRow>
) : (
user.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.uid}>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,321 @@
"use client";
import {
CreateIconIon,
DeleteIcon,
DotsYIcon,
SearchIcon,
} from "@/components/icons";
import { close, error, success } from "@/config/swal";
import { deleteArticle } from "@/service/article";
import { getCustomStaticPage } from "@/service/static-page-service";
import { Article } from "@/types/globals";
import Link from "next/link";
import { Key, useCallback, useEffect, useState } from "react";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "../ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "../ui/input";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import CustomPagination from "../layout/custom-pagination";
const columns = [
{ name: "No", uid: "no" },
{ name: "Judul", uid: "title" },
{ name: "Slug", uid: "slug" },
{ name: "Deskripsi", uid: "description" },
{ name: "Aksi", uid: "actions" },
];
type ArticleData = Article & {
no: number;
createdAt: string;
};
export default function StaticPageTable() {
const MySwal = withReactContent(Swal);
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
const [article, setArticle] = useState<ArticleData[]>([]);
const [showData, setShowData] = useState("10");
const [search, setSearch] = useState("");
const [startDateValue] = useState({
startDate: null,
endDate: null,
});
useEffect(() => {
initState();
}, [page, showData, startDateValue]);
async function initState() {
const req = {
limit: showData,
page: page,
search: search,
};
const res = await getCustomStaticPage(req);
getTableNumber(parseInt(showData), res.data?.data);
console.log("res.data?.data", res.data);
setTotalPage(res?.data?.meta?.totalPage);
}
const getTableNumber = (limit: number, data: Article[]) => {
if (data) {
const startIndex = limit * (page - 1);
let iterate = 0;
const newData = data.map((value: any) => {
iterate++;
value.no = startIndex + iterate;
return value;
});
console.log("daata", data);
setArticle(newData);
}
};
async function doDelete(id: string) {
// loading();
const resDelete = await deleteArticle(id);
if (resDelete?.error) {
error(resDelete.message);
return false;
}
close();
success("Success Deleted");
}
const handleDelete = (id: string) => {
MySwal.fire({
title: "Hapus Data",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
// function successSubmit() {
// MySwal.fire({
// title: "Sukses",
// icon: "success",
// confirmButtonColor: "#3085d6",
// confirmButtonText: "OK",
// }).then((result) => {
// if (result.isConfirmed) {
// // initStete();
// }
// });
// }
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
const cellValue = article[columnKey as keyof ArticleData];
switch (columnKey) {
case "actions":
return (
<div className="relative flex justify-start items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="p-0 h-auto w-auto"
>
<DotsYIcon className="text-black dark:text-white" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-black text-white border">
{/* <DropdownMenuItem>
<Link href={`/admin/static-page/detail/${article.id}`} className="flex items-center">
<EyeIconMdi className="inline mr-2 mb-1" />
Detail
</Link>
</DropdownMenuItem> */}
<DropdownMenuItem asChild>
<Link
href={`/admin/static-page/edit/${article.id}`}
className="flex items-center"
>
<CreateIconIon className="inline mr-2 mb-1" size={20} />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(article.id)}
className="flex items-center text-red-500"
>
<DeleteIcon
color="red"
width={20}
height={16}
className="inline mr-2 mb-1"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
default:
return cellValue;
}
}, []);
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
};
async function doneTyping() {
initState();
}
return (
<>
<div className="py-3">
<div className="flex flex-col items-start rounded-2xl gap-3">
<div className="flex flex-col md:flex-row gap-3 w-full">
<div className="flex flex-col gap-1 w-full lg:w-1/3">
<Label className="font-semibold text-sm">Pencarian</Label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon className="text-base text-muted-foreground" />
</span>
<Input
type="text"
aria-label="Pencarian..."
placeholder="Pencarian..."
className="pl-10 text-sm bg-muted"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
<Label className="font-semibold text-sm">Data</Label>
<Select
value={showData}
onValueChange={(value) =>
value === "" ? "" : setShowData(value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
{/* <div className="flex flex-col gap-1 w-full lg:w-[340px]">
<p className="font-semibold text-sm">Tanggal</p>
<Datepicker
value={startDateValue}
displayFormat="DD/MM/YYYY"
onChange={(e: any) => setStartDateValue(e)}
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
/>
</div> */}
</div>
<Table className="rounded-3xl overflow-hidden border border-gray-200 dark:border-gray-800 shadow-sm bg-white dark:bg-black text-black dark:text-white min-h-[50px]">
<TableHeader>
<TableRow className="bg-white dark:bg-black border-b dark:border-gray-800">
{columns.map((column) => (
<TableHead
key={column.uid}
className="text-left font-semibold text-sm text-black dark:text-white px-4 py-3"
>
{column.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{article.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-4 text-sm"
>
No data to display.
</TableCell>
</TableRow>
) : (
article.map((item) => (
<TableRow key={item.id} className="transition-colors">
{columns.map((column) => (
<TableCell
key={column.uid}
className="px-4 py-3 text-sm border-none"
>
{renderCell(item, column.uid)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
<div className="my-2 w-full flex justify-center">
{/* <Pagination
isCompact
showControls
showShadow
color="primary"
classNames={{
base: "bg-transparent",
wrapper: "bg-transparent",
}}
page={page}
total={totalPage}
onChange={(page) => setPage(page)}
/> */}
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

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