Initial commit
This commit is contained in:
commit
d068721651
|
|
@ -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,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 AdvertiseTable from "@/components/table/advertise/advertise-table";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
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 {
|
||||||
|
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 { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||||
|
import useDisclosure from "@/components/useDisclosure";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<img
|
||||||
|
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,22 @@
|
||||||
|
import BreakingNews from "@/components/landing-page/breaking-news";
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import Header from "@/components/landing-page/header";
|
||||||
|
import HeaderLatest from "@/components/landing-page/header-latest";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
import News from "@/components/landing-page/news";
|
||||||
|
import Opini from "@/components/landing-page/opini";
|
||||||
|
import PopularNews from "@/components/landing-page/popular-news";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col font-[family-name:var(--font-geist-sans)] bg-white">
|
||||||
|
<Navbar />
|
||||||
|
<div className="flex-1">
|
||||||
|
<HeaderLatest />
|
||||||
|
</div>
|
||||||
|
<BreakingNews />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</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,36 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import ChunkErrorBoundary from "@/components/layout/chunk-error-boundary";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Isu Kini",
|
||||||
|
description: "Isu Kini",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<ChunkErrorBoundary>{children}</ChunkErrorBoundary>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import Header from "@/components/landing-page/header";
|
||||||
|
import Navbar from "@/components/landing-page/navbar";
|
||||||
|
import News from "@/components/landing-page/news";
|
||||||
|
import Opini from "@/components/landing-page/opini";
|
||||||
|
import PopularNews from "@/components/landing-page/popular-news";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col font-[family-name:var(--font-geist-sans)] bg-white">
|
||||||
|
<Navbar />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<News />
|
||||||
|
<Opini />
|
||||||
|
<PopularNews />
|
||||||
|
<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,671 @@
|
||||||
|
"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, loading } from "@/config/swal";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Author from "../landing-page/author";
|
||||||
|
|
||||||
|
type TabKey = "trending" | "comments" | "latest";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryType {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailContent() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params?.id;
|
||||||
|
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 [tabArticles, setTabArticles] = useState<Article[]>([]);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("trending");
|
||||||
|
|
||||||
|
const tabs: { id: TabKey; label: string }[] = [
|
||||||
|
{ id: "trending", label: "Trending" },
|
||||||
|
{ id: "comments", label: "Comments" },
|
||||||
|
{ id: "latest", label: "Latest" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTabArticles();
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
async function fetchTabArticles() {
|
||||||
|
const req: any = {
|
||||||
|
limit: showData,
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
categorySlug: Array.from(selectedCategories).join(","),
|
||||||
|
sort: "desc",
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sesuaikan sortBy berdasarkan tab
|
||||||
|
if (activeTab === "trending") {
|
||||||
|
req.sortBy = "view_count";
|
||||||
|
} else if (activeTab === "comments") {
|
||||||
|
req.sortBy = "comment_count";
|
||||||
|
} else {
|
||||||
|
req.sortBy = "created_at";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hanya kirimkan search jika valid
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center bg-[#F2F4F3] w-full overflow-hidden mb-4 py-6 px-8">
|
||||||
|
<Image
|
||||||
|
src={"/mikul.png"}
|
||||||
|
alt="Background"
|
||||||
|
width={272}
|
||||||
|
height={90}
|
||||||
|
className="w-full md:w-[272px] h-[90px] object-cover border"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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-[#31942E]">
|
||||||
|
<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-[#31942E] font-medium">
|
||||||
|
{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">
|
||||||
|
{articleDetail?.files?.[0]?.file_url ? (
|
||||||
|
<Image
|
||||||
|
src={articleDetail.files[0].file_url}
|
||||||
|
alt="Berita"
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
className="rounded-lg w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<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">
|
||||||
|
<p className="text-gray-700 leading-relaxed text-justify">
|
||||||
|
<span className="text-black font-bold text-md">
|
||||||
|
Mikulnews.com -
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{articleDetail?.description}
|
||||||
|
</p>
|
||||||
|
<Author />
|
||||||
|
<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-green-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-green-600">*</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-6 mt-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="komentar"
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
>
|
||||||
|
Komentar <span className="text-green-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-green-600"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="nama"
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
>
|
||||||
|
Nama <span className="text-green-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nama"
|
||||||
|
className="w-full border border-gray-300 rounded-md p-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
>
|
||||||
|
Email <span className="text-green-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
className="w-full border border-gray-300 rounded-md p-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
>
|
||||||
|
Situs Web
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="website"
|
||||||
|
className="w-full border border-gray-300 rounded-md p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2 mt-2">
|
||||||
|
<input type="checkbox" id="saveInfo" className="mt-1" />
|
||||||
|
<label htmlFor="saveInfo" className="text-sm text-gray-700">
|
||||||
|
Simpan nama, email, dan situs web saya pada peramban ini untuk
|
||||||
|
komentar saya berikutnya.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-red-600 text-sm">
|
||||||
|
The reCAPTCHA verification period has expired. Please reload the
|
||||||
|
page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-2 rounded-md transition mt-2"
|
||||||
|
>
|
||||||
|
KIRIM KOMENTAR
|
||||||
|
</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">
|
||||||
|
<Image
|
||||||
|
src={"/kolom.png"}
|
||||||
|
alt="Iklan"
|
||||||
|
width={345}
|
||||||
|
height={345}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<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-green-600 text-green-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-green-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?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,309 @@
|
||||||
|
"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,
|
||||||
|
readonly: readOnly,
|
||||||
|
disabled,
|
||||||
|
branding: false,
|
||||||
|
elementpath: false,
|
||||||
|
resize: false,
|
||||||
|
statusbar: !readOnly,
|
||||||
|
// Performance optimizations
|
||||||
|
cache_suffix: "?v=1.0",
|
||||||
|
browser_spellcheck: false,
|
||||||
|
gecko_spellcheck: false,
|
||||||
|
// Content styling
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// Image upload configuration
|
||||||
|
images_upload_handler: uploadUrl ? handleImageUpload : undefined,
|
||||||
|
automatic_uploads: !!uploadUrl,
|
||||||
|
file_picker_types: "image",
|
||||||
|
// Better mobile support
|
||||||
|
mobile: {
|
||||||
|
theme: "silver",
|
||||||
|
plugins: ["lists", "autolink", "link", "image", "table"],
|
||||||
|
toolbar: "bold italic | bullist numlist | link image",
|
||||||
|
},
|
||||||
|
// Paste configuration
|
||||||
|
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 configuration
|
||||||
|
table_default_styles: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
table_default_attributes: {
|
||||||
|
border: "1",
|
||||||
|
},
|
||||||
|
// Code configuration
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
// ...feature config
|
||||||
|
...featureConfig,
|
||||||
|
// Custom toolbar if provided
|
||||||
|
...(toolbar && { toolbar }),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,825 @@
|
||||||
|
"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 { 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";
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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<any>(null);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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") {
|
||||||
|
const request = {
|
||||||
|
id: articleId,
|
||||||
|
date: `${startDateValue?.year}-${startDateValue?.month}-${startDateValue?.day}`,
|
||||||
|
};
|
||||||
|
const res = await createArticleSchedule(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"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 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">
|
||||||
|
<div className="w-full lg:w-[140px] flex flex-col gal-2 ">
|
||||||
|
<p className="text-sm">Tanggal</p>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full !h-[30px] lg:h-[40px] border-1 rounded-lg text-black"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{startDateValue
|
||||||
|
? convertDateFormatNoTime(startDateValue)
|
||||||
|
: "-"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="bg-transparent p-0">
|
||||||
|
<Calendar
|
||||||
|
selected={startDateValue}
|
||||||
|
onSelect={setStartDateValue}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</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,287 @@
|
||||||
|
"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 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,448 @@
|
||||||
|
"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("Informational");
|
||||||
|
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||||
|
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(true);
|
||||||
|
|
||||||
|
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="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) => {
|
||||||
|
if (value !== "") setSelectedLanguage(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||||
|
<SelectValue placeholder="Bahasa" />
|
||||||
|
</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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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-orange-400 via-orange-500 to-orange-600 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 p-8 shadow-2xl border border-white/20">
|
||||||
|
<img
|
||||||
|
src="/isukini.jpg"
|
||||||
|
alt="Isukini Logo"
|
||||||
|
className="max-w-xs h-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="mt-8 text-white/90">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Portal Isu Kini</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="/isukini.jpg"
|
||||||
|
alt="Mikul News 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-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="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 Isu Kini - 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-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={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,207 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
fileUrl: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BreakingNews() {
|
||||||
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [popular, setPopular] = useState<Article[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticles();
|
||||||
|
fetchPopular();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchArticles() {
|
||||||
|
try {
|
||||||
|
const req = {
|
||||||
|
limit: "5",
|
||||||
|
page: 1,
|
||||||
|
search: "",
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
const res = await getListArticle(req);
|
||||||
|
setArticles(res?.data?.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal memuat artikel:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPopular() {
|
||||||
|
try {
|
||||||
|
const req = {
|
||||||
|
limit: "5",
|
||||||
|
page: 1,
|
||||||
|
search: "",
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "",
|
||||||
|
};
|
||||||
|
const res = await getListArticle(req);
|
||||||
|
setPopular(res?.data?.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal memuat artikel populer:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="max-w-screen-xl mx-auto px-3 md:px-5 py-8 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
{/* Kiri - Breaking News */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{articles.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-4 border-b pb-4">
|
||||||
|
<Image
|
||||||
|
src={item.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={item.title}
|
||||||
|
width={160}
|
||||||
|
height={100}
|
||||||
|
className="object-cover rounded-md w-40 h-28"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold hover:text-blue-600 cursor-pointer">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
By {item.createdByName} •{" "}
|
||||||
|
{new Date(item.createdAt).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tengah - Browse Categories & Topics (dummy) */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-gray-800 border-b pb-2 mb-3">
|
||||||
|
BROWSE BY CATEGORIES
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li>Berita Opini</li>
|
||||||
|
<li>Berita Populer</li>
|
||||||
|
<li>Berita Terkini</li>
|
||||||
|
<li>Jaga Negeri</li>
|
||||||
|
<li>National</li>
|
||||||
|
<li>Politics</li>
|
||||||
|
<li>Ragam Nusantara</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-gray-800 border-b pb-2 mb-3">
|
||||||
|
BROWSE BY TOPICS
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{[
|
||||||
|
"#GIIAS2025",
|
||||||
|
"#mdtranscorp",
|
||||||
|
"2018 League",
|
||||||
|
"Bali United",
|
||||||
|
"Chopper Bike",
|
||||||
|
].map((topic, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-gray-100 px-2 py-1 rounded-md cursor-pointer hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
{topic}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanan - Popular News */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-blue-700 border-b pb-2 mb-3">
|
||||||
|
POPULAR NEWS
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{popular.length > 0 && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Item pertama tampil besar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={popular[0]?.files?.[0]?.fileUrl || "/dummy.jpg"}
|
||||||
|
alt={
|
||||||
|
popular[0]?.files?.[0]?.file_alt ||
|
||||||
|
popular[0]?.title ||
|
||||||
|
"No Title"
|
||||||
|
}
|
||||||
|
width={400}
|
||||||
|
height={200}
|
||||||
|
className="w-full h-48 object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<h5 className="text-sm font-semibold hover:text-blue-600 cursor-pointer">
|
||||||
|
{popular[0]?.title}
|
||||||
|
</h5>
|
||||||
|
<span className="absolute top-2 right-2 text-4xl font-bold text-gray-300/80">
|
||||||
|
01
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item sisanya */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{popular.slice(1).map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex gap-3 items-start border-b pb-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-bold text-gray-400">
|
||||||
|
0{i + 2}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium hover:text-blue-600 cursor-pointer">
|
||||||
|
{item.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-xs text-gray-400">0 Shares</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advert */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Image
|
||||||
|
src="/adversment.png"
|
||||||
|
alt="Advertisement"
|
||||||
|
width={300}
|
||||||
|
height={250}
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
// components/Footer.tsx
|
||||||
|
import { Facebook, Twitter, Instagram, Youtube } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-black text-white py-10 w-full">
|
||||||
|
<div className=" mx-auto flex flex-col items-center justify-between px-14">
|
||||||
|
<div className="flex justify-center ">
|
||||||
|
<Image
|
||||||
|
src="/isukini.jpg"
|
||||||
|
alt="Kritik Tajam Logo"
|
||||||
|
width={1080}
|
||||||
|
height={1000}
|
||||||
|
className="object-contain ml-0 md:ml-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-5/12">
|
||||||
|
<p className="text-[#666666] mb-2 text-center text-sm font-serif">
|
||||||
|
IsuKini.com adalah sebuah media online yang memiliki fokus utama
|
||||||
|
pada pemberitaan dan pencerahan terkait berbagai isu sosial yang
|
||||||
|
relevan. Dalam era informasi yang begitu cepat dan dinamis,
|
||||||
|
IsuKini.com hadir sebagai sumber berita yang mendalam dan mendalam
|
||||||
|
dalam menyoroti permasalahan sosial yang memengaruhi masyarakat.
|
||||||
|
</p>
|
||||||
|
<p className="text-[#666666] my-6 text-sm font-serif text-center">
|
||||||
|
Contact us:{" "}
|
||||||
|
<span className="text-[#795548]">contact@isukini.com</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Follow Us */}
|
||||||
|
<div>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="bg-blue-700 w-10 h-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Facebook size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="bg-sky-500 w-10 h-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Twitter size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="bg-gradient-to-br from-yellow-500 via-pink-500 to-purple-600 w-10 h-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Instagram size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="bg-red-600 w-10 h-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Youtube size={20} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-[#666666] text-sm mt-10 border-gray-800 pt-4">
|
||||||
|
@2023 - isukini.com. All Right Reserved.{" "}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeaderLatest() {
|
||||||
|
const [article, setArticle] = useState<Article | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: "1",
|
||||||
|
page: 1,
|
||||||
|
search: "",
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getListArticle(req);
|
||||||
|
setArticle(res?.data?.data?.[0] || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal memuat artikel:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="w-full pt-3 flex justify-center">
|
||||||
|
<div className="relative max-w-screen-xl w-full h-[320px] md:h-[480px] mx-3 md:mx-5 overflow-hidden group">
|
||||||
|
{/* Gambar utama */}
|
||||||
|
<Image
|
||||||
|
src={article?.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={article?.title || "No Title"}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
{/* Konten teks */}
|
||||||
|
<div className="absolute bottom-5 left-5 text-white max-w-[80%]">
|
||||||
|
<span className="bg-orange-600 text-white text-[10px] md:text-xs px-2 py-1 mb-3 inline-block font-semibold uppercase tracking-wide">
|
||||||
|
{article?.categoryName || "Berita Terkini"}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg md:text-2xl font-semibold leading-snug md:leading-tight">
|
||||||
|
{article?.title || "Memuat..."}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [article, setArticle] = useState<Article | null>(null);
|
||||||
|
const [showData, setShowData] = useState("5");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: 1,
|
||||||
|
search: "",
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getListArticle(req);
|
||||||
|
setArticle(res?.data?.data?.[0] || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal memuat artikel:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white w-full pt-3 flex justify-center">
|
||||||
|
<div className="relative max-w-screen-xl h-auto md:h-[483px] flex flex-col md:flex-row overflow-hidden shadow-md mx-3 md:mx-3 lg:mx-5">
|
||||||
|
<div className="w-full md:w-1/2 h-[250px] md:h-full">
|
||||||
|
<Image
|
||||||
|
src={article?.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={article?.title || "No Title"}
|
||||||
|
width={730}
|
||||||
|
height={483}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-1/2 bg-[#714C3D] text-white p-6 md:p-10 flex flex-col justify-center">
|
||||||
|
<span className="bg-black text-white text-xs px-2 py-1 mb-4 inline-block w-fit">
|
||||||
|
{article?.categoryName || "Berita Terkini"}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-xl md:text-3xl font-semibold leading-snug md:leading-tight mb-4 md:mb-6">
|
||||||
|
{article?.title || "Memuat..."}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm opacity-90">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M0 0h24v24H0z" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 8v4l3 3l1-1l-2.5-2.5V8zM12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2m0 18a8 8 0 1 1 8-8a8.009 8.009 0 0 1-8 8"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{article
|
||||||
|
? new Date(article.createdAt).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M28 4h-2V2h-2v2H8V2H6v2H4a2.006 2.006 0 0 0-2 2v22a2.006 2.006 0 0 0 2 2h24a2.006 2.006 0 0 0 2-2V6a2.006 2.006 0 0 0-2-2m0 24H4V10h24Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{article?.createdByName || ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
import { Facebook, Instagram, Search, Twitter, Youtube } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="relative w-full h-[113px]">
|
||||||
|
<Image
|
||||||
|
src="/bg-isu.jpg"
|
||||||
|
alt="Kritik Tajam Background"
|
||||||
|
width={1920}
|
||||||
|
height={113}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Image
|
||||||
|
src="/isukini.jpg"
|
||||||
|
alt="Kritik Tajam Logo"
|
||||||
|
width={100}
|
||||||
|
height={93}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex max-w-screen-xl mx-auto items-center justify-between py-2 flex-wrap gap-y-2">
|
||||||
|
<div className="flex flex-wrap gap-x-4 md:gap-x-6 font-semibold text-sm mx-3 md:mx-3 lg:mx-3 xl:mx-0">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[#795548] border-t-3 border-[#795548] pb-1"
|
||||||
|
>
|
||||||
|
HOME
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/category/latest-news"
|
||||||
|
className="text-black hover:text-[#795548]"
|
||||||
|
>
|
||||||
|
BERITA TERKINI
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/berita-populer"
|
||||||
|
className="text-black hover:text-[#795548]"
|
||||||
|
>
|
||||||
|
BERITA POPULER
|
||||||
|
</Link>
|
||||||
|
<Link href="/jaga-negeri" className="text-black hover:text-[#795548]">
|
||||||
|
JAGA NEGERI
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/berita-opini"
|
||||||
|
className="text-black hover:text-[#795548]"
|
||||||
|
>
|
||||||
|
BERITA OPINI
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ikon kanan */}
|
||||||
|
<div className="flex items-center space-x-4 text-black mt-2 md:mt-0 mr-3 md:mr-3 lg:mr-3 xl:mr-0">
|
||||||
|
<Link href="#">
|
||||||
|
<Facebook size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link href="#">
|
||||||
|
<Twitter size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link href="#">
|
||||||
|
<Instagram size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link href="#">
|
||||||
|
<Youtube size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link href="#">
|
||||||
|
<Search size={18} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={"/auth"}>
|
||||||
|
<Button className="bg-black text-white rounded-md px-5 py-2 hover:bg-yellow-500">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Facebook,
|
||||||
|
Instagram,
|
||||||
|
RefreshCcwIcon,
|
||||||
|
ThumbsUpIcon,
|
||||||
|
Twitter,
|
||||||
|
UserRoundPlus,
|
||||||
|
Youtube,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: 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("5");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<any>([]);
|
||||||
|
const [startDateValue, setStartDateValue] = useState({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (error) {
|
||||||
|
console.error("Failed to fetch articles:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text: string, wordLimit: number) {
|
||||||
|
const words = text.split(" ");
|
||||||
|
if (words.length <= wordLimit) return text;
|
||||||
|
return words.slice(0, wordLimit).join(" ") + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white py-10 px-4 md:px-10 w-full">
|
||||||
|
<div className="max-w-screen-xl mx-auto flex flex-col lg:flex-row lg:justify-between gap-5">
|
||||||
|
{/* Left: News Section */}
|
||||||
|
<div className="w-full lg:w-2/3">
|
||||||
|
<div className="flex flex-row items-center pb-2 mb-3 gap-4">
|
||||||
|
<h2 className="text-lg font-bold ">BERITA TERKINI</h2>
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
{articles.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex flex-col md:flex-row gap-4 pb-6"
|
||||||
|
>
|
||||||
|
<div className="w-full md:w-[250px]">
|
||||||
|
<Image
|
||||||
|
src={item.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={item.title}
|
||||||
|
width={250}
|
||||||
|
height={180}
|
||||||
|
className="w-full h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="inline-block bg-black text-white text-xs px-2 py-1 mb-2">
|
||||||
|
{item.categoryName || "Berita Terkini"}
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold text-xl font-serif mb-2">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#999999] mb-2 flex items-center gap-2">
|
||||||
|
by {item.createdByName || "admin"}{" "}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M0 0h24v24H0z" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 8v4l3 3l1-1l-2.5-2.5V8zM12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2m0 18a8 8 0 1 1 8-8a8.009 8.009 0 0 1-8 8"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{new Date(item.createdAt).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
<p className="text-[#999999] text-sm font-serif">
|
||||||
|
{truncateText(item.description, 50)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more button */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{page < totalPage && (
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center gap-2 px-6 py-2 text-sm bg-black text-white hover:bg-black hover:text-white transition w-[350px] text-center"
|
||||||
|
onClick={() => setPage((prev) => prev + 1)}
|
||||||
|
>
|
||||||
|
Load More Posts
|
||||||
|
<RefreshCcwIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="relative my-5 max-w-full h-[125px] overflow-hidden flex items-center mx-auto border">
|
||||||
|
<Image
|
||||||
|
src="/image-kolom.png"
|
||||||
|
alt="Berita Utama"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar */}
|
||||||
|
<aside className="w-full lg:w-[340px]">
|
||||||
|
<div className="border p-4 shadow-sm rounded-md">
|
||||||
|
<div className="flex flex-row items-center pb-2 mb-4 gap-4">
|
||||||
|
<h3 className="font-semibold text-sm">SOCIAL NETWORKS</h3>
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md"></div>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
<li className="flex items-center justify-between border px-3 py-2 hover:bg-gray-100 transition rounded">
|
||||||
|
<span className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Facebook className="text-black" />
|
||||||
|
FACEBOOK
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<ThumbsUpIcon size={15} /> LIKE
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between border px-3 py-2 hover:bg-gray-100 transition rounded">
|
||||||
|
<span className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Twitter className="text-black" />
|
||||||
|
TWITTER
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<UserRoundPlus size={15} /> FOLLOW
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between border px-3 py-2 hover:bg-gray-100 transition rounded">
|
||||||
|
<span className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Instagram className="text-black" />
|
||||||
|
INSTAGRAM
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<UserRoundPlus size={15} /> FOLLOW
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between border px-3 py-2 hover:bg-gray-100 transition rounded">
|
||||||
|
<span className="flex items-center gap-2 text-gray-700">
|
||||||
|
<p className=" bg-black rounded-full text-white w-[20px] text-center ml-1">
|
||||||
|
P
|
||||||
|
</p>
|
||||||
|
PINTEREST
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<UserRoundPlus size={15} /> FOLLOW
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between border px-3 py-2 hover:bg-gray-100 transition rounded">
|
||||||
|
<span className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Youtube className="text-black" />
|
||||||
|
YOUTUBE
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<UserRoundPlus size={15} /> SUBSCRIBE
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar banners */}
|
||||||
|
<div className="relative w-[1111px] max-w-full h-[300px] overflow-hidden flex items-center mx-auto border my-6 rounded">
|
||||||
|
<Image
|
||||||
|
src="/kolom.png"
|
||||||
|
alt="Berita Utama"
|
||||||
|
fill
|
||||||
|
className="object-contain rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-[1111px] max-w-full h-[300px] overflow-hidden flex items-center mx-auto border my-6 rounded">
|
||||||
|
<Image
|
||||||
|
src="/kolom.png"
|
||||||
|
alt="Berita Utama"
|
||||||
|
fill
|
||||||
|
className="object-contain rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Opini() {
|
||||||
|
const [opini, setOpini] = useState<Article[]>([]);
|
||||||
|
const [jagaNegeri, setJagaNegeri] = useState<Article[]>([]);
|
||||||
|
const [showDataOpini, setShowDataOpini] = useState("4");
|
||||||
|
const [showData, setShowData] = useState("3");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOpini();
|
||||||
|
fetchJagaNegeri();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchOpini() {
|
||||||
|
try {
|
||||||
|
const res = await getListArticle({
|
||||||
|
limit: showDataOpini,
|
||||||
|
search: "",
|
||||||
|
page: 1,
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
});
|
||||||
|
setOpini(res?.data?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal load Opini:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJagaNegeri() {
|
||||||
|
try {
|
||||||
|
const res = await getListArticle({
|
||||||
|
limit: showData,
|
||||||
|
search: "",
|
||||||
|
page: 1,
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
});
|
||||||
|
setJagaNegeri(res?.data?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal load Jaga Negeri:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full px-4 md:px-12 pt-8">
|
||||||
|
<div className="max-w-screen-xl mx-auto flex flex-col md:flex-row gap-8 justify-between">
|
||||||
|
{/* OPINI */}
|
||||||
|
<div className="w-full md:w-4/12">
|
||||||
|
<h2 className="text-xl font-semibold pb-2 flex items-center justify-between">
|
||||||
|
OPINI
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md mx-3 mt-1"></div>
|
||||||
|
<span className="text-gray-400 text-sm">‹ ›</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
{opini.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-4 border-b pb-4"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={item.title}
|
||||||
|
width={109}
|
||||||
|
height={70}
|
||||||
|
className="w-[109px] h-16 object-cover"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium font-serif">{item.title}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 4a8 8 0 100 16 8 8 0 000-16zm1 4v4.586l2.707 2.707-1.414 1.414L11 13.414V8h2z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{new Date(item.createdAt).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JAGA NEGERI */}
|
||||||
|
<div className="w-full md:w-7/12">
|
||||||
|
<h2 className="text-xl font-semibold border-b pb-2 flex items-center justify-between">
|
||||||
|
JAGA NEGERI
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md mx-3 mt-1"></div>
|
||||||
|
<span className="text-gray-400 text-sm">‹ ›</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{jagaNegeri.length > 0 && (
|
||||||
|
<div className="mt-6 flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="w-full md:w-[455px]">
|
||||||
|
<Image
|
||||||
|
src={jagaNegeri[0].thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={jagaNegeri[0].title}
|
||||||
|
width={700}
|
||||||
|
height={400}
|
||||||
|
className="w-full h-[330px] object-cover"
|
||||||
|
/>
|
||||||
|
<p className="text-lg mt-4 font-serif">{jagaNegeri[0].title}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 flex items-center gap-2">
|
||||||
|
{new Date(jagaNegeri[0].createdAt).toLocaleDateString(
|
||||||
|
"id-ID",
|
||||||
|
{
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-4/12 flex flex-col gap-4">
|
||||||
|
{jagaNegeri.slice(1).map((item) => (
|
||||||
|
<div key={item.id} className="flex flex-col gap-3">
|
||||||
|
<Image
|
||||||
|
src={item.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={item.title}
|
||||||
|
width={100}
|
||||||
|
height={70}
|
||||||
|
className="w-full md:w-[227px] h-auto md:h-[151px] object-cover"
|
||||||
|
/>
|
||||||
|
<p className="text-sm font-serif font-medium leading-snug">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative my-5 max-w-screen-xl h-[125px] overflow-hidden flex items-center mx-auto border">
|
||||||
|
<Image
|
||||||
|
src="/image-kolom.png"
|
||||||
|
alt="Berita Utama"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getListArticle } from "@/service/article";
|
||||||
|
|
||||||
|
type Article = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categoryName: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
categories: {
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
files: {
|
||||||
|
file_url: string;
|
||||||
|
file_alt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PopularNews() {
|
||||||
|
const [popularNews, setPopularNews] = useState<Article[]>([]);
|
||||||
|
const [showData, setShowData] = useState("5");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPopularNews();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchPopularNews() {
|
||||||
|
try {
|
||||||
|
const res = await getListArticle({
|
||||||
|
limit: showData,
|
||||||
|
page: 1,
|
||||||
|
search: "",
|
||||||
|
categorySlug: "",
|
||||||
|
sort: "desc",
|
||||||
|
isPublish: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
});
|
||||||
|
|
||||||
|
setPopularNews(res?.data?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal load berita populer:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full px-4 md:px-12 ">
|
||||||
|
<div className="max-w-screen-xl mx-auto">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md mx-3 mt-1"></div>
|
||||||
|
<h2 className="text-center text-lg font-semibold py-3">
|
||||||
|
BERITA POPULER
|
||||||
|
</h2>
|
||||||
|
<div className="flex-grow border-t-3 border-gray-300 rounded-md mx-3 mt-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{popularNews.slice(0, 3).map((news) => (
|
||||||
|
<div
|
||||||
|
key={news.id}
|
||||||
|
className="relative h-[358px] group overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={news.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={news.title}
|
||||||
|
width={361}
|
||||||
|
height={358}
|
||||||
|
className="w-full h-[358px] object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent text-white p-3">
|
||||||
|
<p className="text-xl font-serif font-medium">{news.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{popularNews.slice(3).map((news) => (
|
||||||
|
<div
|
||||||
|
key={news.id}
|
||||||
|
className="relative h-[267px] group overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={news.thumbnailUrl || "/dummy.jpg"}
|
||||||
|
alt={news.title}
|
||||||
|
width={500}
|
||||||
|
height={267}
|
||||||
|
className="w-full h-[267px] object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent text-white p-3">
|
||||||
|
<p className="text-xl font-serif font-medium">{news.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="relative mt-5 mb-14 max-w-screen-xl h-[125px] overflow-hidden flex items-center mx-auto border">
|
||||||
|
<Image
|
||||||
|
src="/image-kolom.png"
|
||||||
|
alt="Berita Utama"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,627 @@
|
||||||
|
"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 { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useTheme } from "../layout/theme-context";
|
||||||
|
import Option from "./option";
|
||||||
|
|
||||||
|
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="/isukini.jpg"
|
||||||
|
className="w-10 h-10 rounded-sm 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">
|
||||||
|
Isu kini
|
||||||
|
</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,88 @@
|
||||||
|
"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 { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { RetractingSidebar } from "../landing-page/retracting-sidedar";
|
||||||
|
|
||||||
|
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,383 @@
|
||||||
|
"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: "4",
|
||||||
|
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,448 @@
|
||||||
|
"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 } 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";
|
||||||
|
|
||||||
|
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: "createdByName" },
|
||||||
|
{ name: "Status", uid: "isPublish" },
|
||||||
|
{ name: "Aksi", uid: "actions" },
|
||||||
|
];
|
||||||
|
const columnsOtherRole = [
|
||||||
|
{ name: "No", uid: "no" },
|
||||||
|
{ name: "Judul", uid: "title" },
|
||||||
|
{ name: "Kategori", uid: "category" },
|
||||||
|
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||||
|
{ name: "Kreator", uid: "createdByName" },
|
||||||
|
{ 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 [startDateValue, setStartDateValue] = useState({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initState();
|
||||||
|
getCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function getCategories() {
|
||||||
|
const res = await getArticleByCategory();
|
||||||
|
const data = res?.data?.data;
|
||||||
|
setCategories(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initState() {
|
||||||
|
loading();
|
||||||
|
const req = {
|
||||||
|
limit: showData,
|
||||||
|
page: page,
|
||||||
|
search: search,
|
||||||
|
categorySlug: Array.from(selectedCategories).join(","),
|
||||||
|
sort: "desc",
|
||||||
|
sortBy: "created_at",
|
||||||
|
};
|
||||||
|
const res = await getArticlePagination(req);
|
||||||
|
await getTableNumber(parseInt(showData), res.data?.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, slug: string) => {
|
||||||
|
const url =
|
||||||
|
`${window.location.protocol}//${window.location.host}` +
|
||||||
|
"/news/detail/" +
|
||||||
|
`${id}-${slug}`;
|
||||||
|
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 "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, article.slug)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{(username === "admin-mabes" ||
|
||||||
|
Number(userId) === article.createdById) && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(username === "admin-mabes" ||
|
||||||
|
Number(userId) === article.createdById) && (
|
||||||
|
<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={selectedCategories}
|
||||||
|
onValueChange={(value) => setSelectedCategories(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full text-sm border">
|
||||||
|
<SelectValue placeholder="Kategori" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories
|
||||||
|
?.filter((category: any) => category.slug != null)
|
||||||
|
.map((category: any) => (
|
||||||
|
<SelectItem key={category.slug} value={category.slug}>
|
||||||
|
{category.title}
|
||||||
|
</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
|
||||||
|
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="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 }
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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 buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row gap-2",
|
||||||
|
month: "flex flex-col gap-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-x-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: cn(
|
||||||
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||||
|
: "[&:has([aria-selected])]:rounded-md"
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"size-8 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_start:
|
||||||
|
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_range_end:
|
||||||
|
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={
|
||||||
|
{
|
||||||
|
// IconLeft: ({ className, ...props }) => (
|
||||||
|
// <ChevronLeft className={cn("size-4", className)} {...props} />
|
||||||
|
// ),
|
||||||
|
// IconRight: ({ className, ...props }) => (
|
||||||
|
// <ChevronRight className={cn("size-4", className)} {...props} />
|
||||||
|
// ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export type ChipColor = "default" | "primary" | "success" | "danger";
|
||||||
|
|
||||||
|
export interface ChipProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: ChipColor;
|
||||||
|
className?: string;
|
||||||
|
size?: string;
|
||||||
|
variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<ChipColor, string> = {
|
||||||
|
default: "bg-gray-200 text-gray-800",
|
||||||
|
primary: "bg-blue-100 text-blue-800",
|
||||||
|
success: "bg-green-100 text-green-800",
|
||||||
|
danger: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Chip: React.FC<ChipProps> = ({ children, color = "default", className }) => {
|
||||||
|
return <span className={clsx("inline-flex items-center px-3 py-1 rounded-full text-sm font-medium", colorMap[color], className)}>{children}</span>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue