From c1c8f658cd7efba91d4c46fb8a0eff8d628eec9a Mon Sep 17 00:00:00 2001 From: Anang Yusman Date: Thu, 26 Feb 2026 13:01:39 +0800 Subject: [PATCH] detail article image --- .../news-article/image/detail/[id]/page.tsx | 11 + components/form/article/edit-article-form.tsx | 814 ++++++++++++++++++ components/main/news-image.tsx | 10 +- 3 files changed, 832 insertions(+), 3 deletions(-) create mode 100644 app/(admin)/admin/news-article/image/detail/[id]/page.tsx create mode 100644 components/form/article/edit-article-form.tsx diff --git a/app/(admin)/admin/news-article/image/detail/[id]/page.tsx b/app/(admin)/admin/news-article/image/detail/[id]/page.tsx new file mode 100644 index 0000000..a502c0a --- /dev/null +++ b/app/(admin)/admin/news-article/image/detail/[id]/page.tsx @@ -0,0 +1,11 @@ +import EditArticleForm from "@/components/form/article/edit-article-form"; + +export default function DetailArticlePage() { + return ( +
+
+ +
+
+ ); +} diff --git a/components/form/article/edit-article-form.tsx b/components/form/article/edit-article-form.tsx new file mode 100644 index 0000000..f4f5844 --- /dev/null +++ b/components/form/article/edit-article-form.tsx @@ -0,0 +1,814 @@ +"use client"; +import { Fragment, useEffect, useRef, useState } 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 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 GenerateSingleArticleForm from "./generate-ai-single-form"; +import { htmlToString } from "@/utils/global"; +import { close, error, loading } from "@/config/swal"; +import { useParams, useRouter } from "next/navigation"; +import GetSeoScore from "./get-seo-score-form"; +import Link from "next/link"; +import Cookies from "js-cookie"; +import { + createArticleSchedule, + deleteArticleFiles, + getArticleByCategory, + getArticleById, + getArticleFiles, + submitApproval, + unPublishArticle, + updateArticle, + uploadArticleFile, + uploadArticleThumbnail, +} from "@/service/article"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Mail, X } from "lucide-react"; +import { format } from "date-fns"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogClose, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import DatePicker from "react-datepicker"; +import { Switch } from "@/components/ui/switch"; + +const ViewEditor = dynamic( + () => { + return import("@/components/editor/view-editor"); + }, + { ssr: false }, +); +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(), +}); + +const createArticleSchema = z.object({ + title: z.string().min(2, { + message: "Judul harus diisi", + }), + customCreatorName: z.string().min(2, { + message: "Judul harus diisi", + }), + slug: z.string().min(2, { + message: "Slug harus diisi", + }), + description: z.string().min(2, { + message: "Deskripsi harus diisi", + }), + category: z.array(categorySchema).nonempty({ + message: "Kategori harus memiliki setidaknya satu item", + }), + tags: z.array(z.string()).nonempty({ + message: "Minimal 1 tag", + }), + source: z.enum(["internal", "external"]).optional(), +}); + +interface DiseData { + id: number; + articleBody: string; + title: string; + metaTitle: string; + description: string; + metaDescription: string; + mainKeyword: string; + additionalKeywords: string; +} + +export default function EditArticleForm(props: { isDetail: boolean }) { + const { isDetail } = props; + const params = useParams(); + const id = params?.id; + const username = Cookies.get("username"); + const userId = Cookies.get("uie"); + const animatedComponents = makeAnimated(); + const MySwal = withReactContent(Swal); + const router = useRouter(); + const editor = useRef(null); + const [files, setFiles] = useState([]); + const [useAi, setUseAI] = useState(false); + const [listCategory, setListCategory] = useState([]); + const [tag, setTag] = useState(""); + const [detailfiles, setDetailFiles] = useState([]); + const [mainImage, setMainImage] = useState(0); + const [thumbnail, setThumbnail] = useState(""); + const [diseId, setDiseId] = useState(0); + const [thumbnailImg, setThumbnailImg] = useState([]); + const [selectedMainImage, setSelectedMainImage] = useState( + null, + ); + const [thumbnailValidation, setThumbnailValidation] = useState(""); + // const { isOpen, onOpen, onOpenChange } = useDisclosure(); + const [isOpen, setIsOpen] = useState(false); + const onOpen = () => setIsOpen(true); + const onOpenChange = () => setIsOpen((prev) => !prev); + const [approvalStatus, setApprovalStatus] = useState(2); + const [approvalMessage, setApprovalMessage] = useState(""); + const [detailData, setDetailData] = useState(); + // const [startDateValue, setStartDateValue] = useState(null); + // const [timeValue, setTimeValue] = useState("00:00"); + const [status, setStatus] = useState<"publish" | "draft" | "scheduled">( + "publish", + ); + const [isScheduled, setIsScheduled] = useState(false); + const [startDateValue, setStartDateValue] = useState(); + const [startTimeValue, setStartTimeValue] = useState(""); + + 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; + const { + register, + control, + handleSubmit, + formState: { errors }, + setValue, + getValues, + watch, + setError, + clearErrors, + } = useForm(formOptions); + + useEffect(() => { + initState(); + }, [listCategory]); + + async function initState() { + loading(); + try { + // 1️⃣ Ambil ARTICLE + const articleRes = await getArticleById(id); + const articleData = articleRes.data?.data; + + if (!articleData) return; + + // ===== ARTICLE DATA ===== + setDetailData(articleData); + setValue("title", articleData.title); + setValue("customCreatorName", articleData.customCreatorName); + setValue("slug", articleData.slug); + setValue("source", articleData.source); + + const cleanDescription = articleData.htmlDescription + ? articleData.htmlDescription + .replace(/\\"/g, '"') + .replace(/\\n/g, "\n") + .trim() + : ""; + + setValue("description", cleanDescription); + setValue("tags", articleData.tags ? articleData.tags.split(",") : []); + + setThumbnail(articleData.thumbnailUrl); + setDiseId(articleData.aiArticleId); + setupInitCategory(articleData.categories); + + const filesRes = await getArticleFiles(); + const allFiles = filesRes.data?.data ?? []; + + const filteredFiles = allFiles.filter( + (file: any) => file.articleId === articleData.id, + ); + + setDetailFiles(filteredFiles); + } catch (error) { + console.error("Init state error:", error); + } finally { + close(); + } + } + + const setupInitCategory = (data: any) => { + const temp: CategoryType[] = []; + for (let i = 0; i < data?.length; i++) { + const datas = listCategory.filter((a) => a.id == data[i].id); + if (datas[0]) { + temp.push(datas[0]); + } + } + setValue("category", temp as [CategoryType, ...CategoryType[]]); + }; + + 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) => { + MySwal.fire({ + title: "Simpan Data", + text: "", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: "Simpan", + }).then((result) => { + if (result.isConfirmed) { + save(values); + } + }); + }; + + const doPublish = async () => { + MySwal.fire({ + title: isScheduled ? "Jadwalkan Publikasi?" : "Publish Artikel Sekarang?", + text: isScheduled + ? "Artikel akan dipublish otomatis sesuai tanggal dan waktu yang kamu pilih." + : "", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: isScheduled ? "Jadwalkan" : "Publish", + }).then((result) => { + if (result.isConfirmed) { + if (isScheduled) { + setStatus("scheduled"); + publishScheduled(); + } else { + publishNow(); + } + } + }); + }; + + const publishNow = async () => { + const response = await updateArticle(String(id), { + id: Number(id), + isPublish: true, + title: detailData?.title, + typeId: 1, + slug: detailData?.slug, + categoryIds: getValues("category") + .map((val) => val.id) + .join(","), + tags: getValues("tags").join(","), + description: htmlToString(getValues("description")), + htmlDescription: getValues("description"), + }); + + if (response?.error) { + error(response.message); + return; + } + + successSubmit("/admin/article"); + }; + + const publishScheduled = async () => { + if (!startDateValue) { + error("Tanggal belum dipilih!"); + return; + } + + const [hours, minutes] = startTimeValue + ? startTimeValue.split(":").map(Number) + : [0, 0]; + + const combinedDate = new Date(startDateValue); + combinedDate.setHours(hours, minutes, 0, 0); + + const formattedDateTime = `${combinedDate.getFullYear()}-${String( + combinedDate.getMonth() + 1, + ).padStart(2, "0")}-${String(combinedDate.getDate()).padStart( + 2, + "0", + )} ${String(combinedDate.getHours()).padStart(2, "0")}:${String( + combinedDate.getMinutes(), + ).padStart(2, "0")}:00`; + + const response = await updateArticle(String(id), { + id: Number(id), + isPublish: false, + title: detailData?.title, + typeId: 1, + slug: detailData?.slug, + categoryIds: getValues("category") + .map((val) => val.id) + .join(","), + tags: getValues("tags").join(","), + description: htmlToString(getValues("description")), + htmlDescription: getValues("description"), + }); + + if (response?.error) { + error(response.message); + return; + } + + const articleId = response?.data?.data?.id ?? id; + const scheduleReq = { + id: articleId, + date: formattedDateTime, + }; + + console.log("📅 Mengirim jadwal publish:", scheduleReq); + const res = await createArticleSchedule(scheduleReq); + + if (res?.error) { + error("Gagal membuat jadwal publikasi."); + return; + } + + successSubmit("/admin/article"); + }; + + const save = async (values: z.infer) => { + loading(); + const formData: any = { + id: Number(id), + title: values.title, + typeId: 1, + slug: values.slug, + categoryIds: values.category.map((val) => val.id).join(","), + tags: values.tags.join(","), + description: htmlToString(values.description), + htmlDescription: values.description, + // createdAt: `${startDateValue} ${timeValue}:00`, + }; + + // if (startDateValue && timeValue) { + // formData.createdAt = `${startDateValue} ${timeValue}:00`; + // } + const response = await updateArticle(String(id), formData); + + if (response?.error) { + error(response.message); + return false; + } + const articleId = response?.data?.data?.id; + const formFiles = new FormData(); + + if (files?.length > 0) { + for (const element of files) { + formFiles.append("file", element); + const resFile = await uploadArticleFile(String(id), formFiles); + } + } + + if (thumbnailImg?.length > 0) { + const formFiles = new FormData(); + + formFiles.append("files", thumbnailImg[0]); + const resFile = await uploadArticleThumbnail(String(id), formFiles); + } + + if (status === "scheduled" && startDateValue) { + // ambil waktu, default 00:00 jika belum diisi + const [hours, minutes] = startTimeValue + ? startTimeValue.split(":").map(Number) + : [0, 0]; + + // gabungkan tanggal + waktu + const combinedDate = new Date(startDateValue); + combinedDate.setHours(hours, minutes, 0, 0); + + // format: 2025-10-08 14:30:00 + const formattedDateTime = `${combinedDate.getFullYear()}-${String( + combinedDate.getMonth() + 1, + ).padStart(2, "0")}-${String(combinedDate.getDate()).padStart( + 2, + "0", + )} ${String(combinedDate.getHours()).padStart(2, "0")}:${String( + combinedDate.getMinutes(), + ).padStart(2, "0")}:00`; + + const request = { + id: articleId, + date: formattedDateTime, + }; + + console.log("📤 Sending schedule request:", request); + const res = await createArticleSchedule(request); + console.log("✅ Schedule response:", res); + } + + close(); + successSubmitData(); + }; + + function successSubmit(redirect: string) { + MySwal.fire({ + title: "Sukses", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "OK", + }).then((result) => { + if (result.isConfirmed) { + router.push(redirect); + } + }); + } + + function successSubmitData() { + MySwal.fire({ + title: "Berhasil disimpan!", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "OK", + }); + } + + const doUnpublish = async () => { + MySwal.fire({ + title: "Unpublish Artikel?", + text: "Artikel akan dihapus dari publik dan tidak tampil lagi.", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: "Ya, Unpublish", + }).then(async (result) => { + if (result.isConfirmed) { + loading(); + const response = await unPublishArticle(String(id), { + id: Number(id), + isPublish: false, + title: detailData?.title, + typeId: 1, + slug: detailData?.slug, + categoryIds: getValues("category") + .map((val) => val.id) + .join(","), + tags: getValues("tags").join(","), + description: htmlToString(getValues("description")), + htmlDescription: getValues("description"), + }); + + if (response?.error) { + error(response.message); + return; + } + + successSubmit("/admin/article"); + } + }); + }; + + 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 ( + {file.name} + ); + } 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) => ( +
+
+
{renderFilePreview(file)}
+
+
{file.name}
+
+ {Math.round(file.size / 100) / 10 > 1000 ? ( + <>{(Math.round(file.size / 100) / 10000).toFixed(1)} + ) : ( + <>{(Math.round(file.size / 100) / 10).toFixed(1)} + )} + {" kb"} +
+
+
+ + +
+ )); + + const handleDeleteFile = (id: number) => { + MySwal.fire({ + title: "Hapus File", + text: "", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: "Hapus", + }).then((result) => { + if (result.isConfirmed) { + deleteFile(id); + } + }); + }; + + const deleteFile = async (id: number) => { + loading(); + const res = await deleteArticleFiles(id); + + if (res?.error) { + error(res.message); + return false; + } + close(); + initState(); + MySwal.fire({ + title: "Sukses", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "OK", + }).then((result) => { + if (result.isConfirmed) { + } + }); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const selectedFiles = event.target.files; + if (selectedFiles) { + setThumbnailImg(Array.from(selectedFiles)); + } + }; + + const approval = async () => { + loading(); + const req = { + articleId: Number(id), + message: approvalMessage, + statusId: approvalStatus, + }; + const res = await submitApproval(req); + + if (res?.error) { + error(res.message); + return false; + } + close(); + initState(); + + MySwal.fire({ + title: "Sukses", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "OK", + }).then((result) => { + if (result.isConfirmed) { + } + }); + }; + + const doApproval = () => { + MySwal.fire({ + title: "Submit Data?", + text: "", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: "Submit", + }).then((result) => { + if (result.isConfirmed) { + approval(); + } + }); + }; + + if (isDetail) { + return ( +
+
+ {/* ================= LEFT SIDE ================= */} +
+ {/* Title */} +
+

Title

+
+ {detailData?.title} +
+
+ + {/* Category */} +
+

Category

+
+ {detailData?.categories + ?.map((cat: any) => cat.title) + .join(", ")} +
+
+ + {/* Description */} +
+

Description

+
+ +
+
+ + {/* Media File */} +
+

Media File

+ + {detailfiles?.length > 0 ? ( + <> + media + +
+ {detailfiles.map((file: any, index: number) => ( + preview + ))} +
+ + ) : ( +

Belum ada file

+ )} +
+
+ + {/* ================= RIGHT SIDE ================= */} +
+ {/* Creator & Thumbnail Card */} +
+ {/* Creator */} +
+

Creator

+
+ {detailData?.customCreatorName || "-"} +
+
+ + {/* Thumbnail */} +
+

Thumbnail Image

+ thumbnail +
+ + {/* Tag */} +
+

Tag

+
+ {detailData?.tags + ?.split(",") + .map((tag: string, i: number) => ( + + {tag} + + ))} +
+
+ + {/* Notes */} +
+

Notes

+
+ - +
+
+
+ +

Suggestion Box (0)

+
+
+ + {/* Action Button */} +
+ + + +
+
+
+
+ ); + } +} diff --git a/components/main/news-image.tsx b/components/main/news-image.tsx index 35d28db..deb8782 100644 --- a/components/main/news-image.tsx +++ b/components/main/news-image.tsx @@ -151,9 +151,13 @@ export default function NewsImage() { {formatDate(article.createdAt)} - + + +