Initial commit
This commit is contained in:
commit
dd56258207
|
|
@ -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
|
||||
|
|
@ -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-arah-negeri:dev .
|
||||
- docker push 103.82.242.92:8900/medols/web-arah-negeri: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-arah-negeri/build?token=autodeploymedols
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <> {children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import Login from "@/components/form/login";
|
||||
import React from "react";
|
||||
|
||||
export default function AuthPage() {
|
||||
return (
|
||||
<>
|
||||
<Login />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import Development from "@/components/landing-page/development";
|
||||
import Footer from "@/components/landing-page/footer";
|
||||
import Header from "@/components/landing-page/header";
|
||||
import Health from "@/components/landing-page/health";
|
||||
import LatestNews from "@/components/landing-page/latest-news";
|
||||
import Navbar from "@/components/landing-page/navbar";
|
||||
|
||||
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>
|
||||
<LatestNews />
|
||||
<Development />
|
||||
<Health />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
<
|
||||
</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}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 Papua’s 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 ")
|
||||
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">Bē</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.
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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-blue-600 via-blue-700 to-blue-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="/arah-negeri.png"
|
||||
alt="Arah Negeri 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 Arah Negeri</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="/arah-negeri.png"
|
||||
alt="Arah Negeri 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-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-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-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-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 Arah Negeri - 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-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-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-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-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-blue-800">
|
||||
<p className="font-medium mb-1">Informasi Portal</p>
|
||||
<p className="text-blue-700">Akses informasi terkini dan status permintaan informasi yang telah diajukan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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, "<").replace(/>/g, ">")}</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// components/Footer.tsx
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-[#002C5B] text-[#FFFFFFCC] 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-col md:flex-row justify-center md:justify-between gap-3">
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2 md:gap-3 text-xs text-[#FFFFFFCC]">
|
||||
{[
|
||||
"Kode Etik Jurnalistik",
|
||||
"Kebijakan Privasi",
|
||||
"Tentang Kami",
|
||||
"Disclaimer",
|
||||
"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>
|
||||
<div className="flex flex-wrap justify-center md:justify-end gap-2 md:gap-3 text-xs text-[#FFFFFF]">
|
||||
<p className="text-xs font-bold text-[#FFFFFF] mb-2 md:mb-0 w-10/12 text-right">
|
||||
© Copyright Arahnegeri Team All Rights Reserved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
"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 Header() {
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
const req = {
|
||||
limit: "2",
|
||||
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="max-w-7xl mx-auto bg-white">
|
||||
{/* Header Banner */}
|
||||
<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>
|
||||
|
||||
<p className="text-xl font-serif font-semibold border-b-2 my-6 mx-4">
|
||||
BERITA UTAMA
|
||||
</p>
|
||||
|
||||
{/* Grid Artikel */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-screen-xl mx-auto px-4">
|
||||
{articles.length > 0 ? (
|
||||
articles.map((item, index) => (
|
||||
<div key={item.id} className="relative h-[410px] w-full">
|
||||
<Link href={`/detail/${item.id}`}>
|
||||
<Image
|
||||
src={
|
||||
item.thumbnailUrl ||
|
||||
item.files?.[0]?.fileUrl ||
|
||||
"/placeholder.jpg"
|
||||
}
|
||||
alt={item.files?.[0]?.file_alt || item.title}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
|
||||
{/* Overlay gradient */}
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
index % 2 === 0
|
||||
? "bg-gradient-to-r from-indigo-900/80 to-red-500/60"
|
||||
: "bg-gradient-to-r from-green-900/80 to-yellow-500/60"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Text content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center text-white px-4 z-20">
|
||||
<span className="text-[10px] bg-yellow-400 text-black px-2 py-1 mb-3 uppercase">
|
||||
{item.categoryName ||
|
||||
item.categories?.[0]?.title ||
|
||||
"Berita"}
|
||||
</span>
|
||||
<h2 className="text-xl md:text-2xl font-semibold leading-snug w-7/12">
|
||||
{item.title}
|
||||
</h2>
|
||||
<div className="flex flex-row items-center mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="mr-1"
|
||||
>
|
||||
<path d="M12 1.75a10.25 10.25 0 1 0 0 20.5a10.25 10.25 0 0 0 0-20.5Zm0 1.5a8.75 8.75 0 1 1 0 17.5a8.75 8.75 0 0 1 0-17.5Zm.75 4a.75.75 0 0 0-1.5 0v5.25a.75.75 0 0 0 .313.613l3 2.25a.75.75 0 0 0 .874-1.222l-2.687-2.016V7.25Z" />
|
||||
</svg>
|
||||
<p className="text-xs text-white">
|
||||
{new Date(item.createdAt).toLocaleDateString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center col-span-2 text-gray-500">
|
||||
Tidak ada artikel tersedia.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu, Lock, Search } from "lucide-react";
|
||||
import { Input } from "../ui/input";
|
||||
import Image from "next/image";
|
||||
|
||||
const Navbar = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const toggleMenu = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<nav className="w-full bg-[#002B5A]">
|
||||
{/* Top Bar */}
|
||||
<div className=" max-w-7xl mx-auto text-white py-3 px-4 flex flex-col md:flex-row justify-between items-center gap-y-2">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src="/arah-negeri.png"
|
||||
alt="Logo"
|
||||
width={130}
|
||||
height={92}
|
||||
className="w-[130px] h-[92px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="flex-grow w-full px-4 max-w-xl mx-auto">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="pr-10 bg-white text-black rounded-sm w-full"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date & Login */}
|
||||
<div className="flex items-center gap-4 text-sm whitespace-nowrap">
|
||||
<span className="text-[#B2C0CD]">Kamis, Maret 27, 2025</span>
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<Lock className="w-4 h-4" />
|
||||
<Link href="/auth" className="font-medium hover:underline">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div className="bg-[#001c47] text-white px-4 py-2">
|
||||
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
||||
{/* Kiri: Burger + Menu */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Burger button (mobile only) */}
|
||||
<button onClick={toggleMenu} className="text-white ">
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Nav Menu (hidden on mobile) */}
|
||||
<div className="hidden md:flex gap-6 font-semibold text-sm">
|
||||
<Link href="/" className="text-yellow-400">
|
||||
BERANDA
|
||||
</Link>
|
||||
<Link
|
||||
href="/category/citizen-news"
|
||||
className="hover:text-yellow-400"
|
||||
>
|
||||
BERITA WARGA
|
||||
</Link>
|
||||
<Link
|
||||
href="/category/development"
|
||||
className="hover:text-yellow-400"
|
||||
>
|
||||
PEMBANGUNAN
|
||||
</Link>
|
||||
<Link href="/category/health" className="hover:text-yellow-400">
|
||||
KESEHATAN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanan: Sosmed */}
|
||||
<div className="flex gap-4 text-white text-lg">
|
||||
<Link href="#" className="text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
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-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
// fill-rule="evenodd"
|
||||
d="M22.54 6.42a2.77 2.77 0 0 0-1.945-1.957C18.88 4 12 4 12 4s-6.88 0-8.595.463A2.77 2.77 0 0 0 1.46 6.42C1 8.148 1 11.75 1 11.75s0 3.602.46 5.33a2.77 2.77 0 0 0 1.945 1.958C5.121 19.5 12 19.5 12 19.5s6.88 0 8.595-.462a2.77 2.77 0 0 0 1.945-1.958c.46-1.726.46-5.33.46-5.33s0-3.602-.46-5.33M9.75 8.479v6.542l5.75-3.271z"
|
||||
// clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link href="#" className="text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
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-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
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-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
// fill-rule="evenodd"
|
||||
d="M23.45 5.948c.166-.546 0-.948-.795-.948H20.03c-.668 0-.976.347-1.143.73c0 0-1.335 3.196-3.226 5.272c-.612.602-.89.793-1.224.793c-.167 0-.418-.191-.418-.738V5.948c0-.656-.184-.948-.74-.948H9.151c-.417 0-.668.304-.668.593c0 .621.946.765 1.043 2.513v3.798c0 .833-.153.984-.487.984c-.89 0-3.055-3.211-4.34-6.885C4.45 5.288 4.198 5 3.527 5H.9c-.75 0-.9.347-.9.73c0 .682.89 4.07 4.145 8.551C6.315 17.341 9.37 19 12.153 19c1.669 0 1.875-.368 1.875-1.003v-2.313c0-.737.158-.884.687-.884c.39 0 1.057.192 2.615 1.667C19.11 18.216 19.403 19 20.405 19h2.625c.75 0 1.126-.368.91-1.096c-.238-.724-1.088-1.775-2.215-3.022c-.612-.71-1.53-1.475-1.809-1.858c-.389-.491-.278-.71 0-1.147c0 0 3.2-4.426 3.533-5.929"
|
||||
// clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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="/arah-negeri.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">
|
||||
Arah Negeri
|
||||
</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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue