"use client"; import { 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 { 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, 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 { type ArticleContentKind, ARTICLE_KIND_LABEL, ARTICLE_KIND_TO_TYPE_ID, articleListPath, } from "@/constants/article-content-types"; const CustomEditor = dynamic( () => import("@/components/editor/custom-editor"), { ssr: false }, ); interface FileWithPreview extends File { preview?: string; } const schema = z.object({ title: z.string().min(2, "Title is required"), slug: z.string().min(2, "Slug is required"), description: z.string().min(2, "Description is required"), tags: z.array(z.string()).min(1, "Add at least one tag"), }); type FormValues = z.infer; function removeImgTags(htmlString: string) { const parser = new DOMParser(); const doc = parser.parseFromString(String(htmlString), "text/html"); doc.querySelectorAll("img").forEach((img) => img.remove()); return doc.body.innerHTML; } function mediaAcceptForKind(kind: ArticleContentKind): Record { switch (kind) { case "image": return { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] }; case "video": return { "video/*": [".mp4", ".webm", ".mov"] }; case "audio": return { "audio/*": [".mp3", ".wav", ".mpeg", ".ogg"] }; default: return {}; } } type Props = { contentKind: ArticleContentKind }; export default function CreateArticleForm({ contentKind }: Props) { const MySwal = withReactContent(Swal); const router = useRouter(); const username = Cookies.get("username")?.trim() || "Editor"; const typeId = ARTICLE_KIND_TO_TYPE_ID[contentKind]; const label = ARTICLE_KIND_LABEL[contentKind]; const requireMedia = contentKind !== "text"; const listHref = articleListPath(contentKind); const [files, setFiles] = useState([]); const [tagInput, setTagInput] = useState(""); const [thumbnailImg, setThumbnailImg] = useState([]); const [selectedMainImage, setSelectedMainImage] = useState(null); const [filesValidation, setFileValidation] = useState(""); const [thumbnailValidation, setThumbnailValidation] = useState(""); const [status, setStatus] = useState<"publish" | "draft" | "scheduled">("publish"); const [isScheduled, setIsScheduled] = useState(false); const [startDateValue, setStartDateValue] = useState(); const [startTimeValue, setStartTimeValue] = useState(""); const dropAccept = mediaAcceptForKind(contentKind); const { getRootProps, getInputProps } = useDropzone({ onDrop: (acceptedFiles) => { setFiles((prev) => [...prev, ...acceptedFiles.map((f) => Object.assign(f))]); }, multiple: true, accept: Object.keys(dropAccept).length ? dropAccept : undefined, disabled: !requireMedia, }); const form = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", slug: "", description: "", tags: [] }, }); const { control, handleSubmit, formState: { errors }, setValue, watch, setError, clearErrors, } = form; const watchTitle = watch("title"); useEffect(() => { const slug = watchTitle .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-"); setValue("slug", slug); }, [watchTitle, setValue]); const onSubmit = async (values: FormValues) => { if (requireMedia && files.length < 1) { setFileValidation("Upload at least one media file"); return; } setFileValidation(""); setThumbnailValidation(""); const ok = await MySwal.fire({ title: "Save article?", icon: "question", showCancelButton: true, }); if (!ok.isConfirmed) return; await save(values); }; const save = async (values: FormValues) => { loading(); try { const plain = htmlToString(removeImgTags(values.description)); const html = removeImgTags(values.description); const formData: Record = { title: values.title, typeId, slug: values.slug, categoryIds: "", tags: values.tags.join(","), description: plain, htmlDescription: html, aiArticleId: null, customCreatorName: username, source: "internal", isDraft: status === "draft", isPublish: status === "publish", }; const response = await createArticle(formData); if ((response as any)?.error) { error((response as any).message); return; } const articleId = (response as any)?.data?.data?.id as number | undefined; if (!articleId) { error("Could not read new article id"); return; } if (files.length > 0) { for (const file of files) { const fd = new FormData(); fd.append("file", file); const up = await uploadArticleFile(String(articleId), fd); if ((up as any)?.error) { error((up as any)?.message ?? "File upload failed"); return; } } } if (thumbnailImg.length > 0 || (selectedMainImage && files.length >= selectedMainImage)) { const fd = new FormData(); if (thumbnailImg.length > 0) { fd.append("files", thumbnailImg[0]); } else if (selectedMainImage) { fd.append("files", files[selectedMainImage - 1]); } const tr = await uploadArticleThumbnail(String(articleId), fd); if ((tr as any)?.error) { error((tr as any)?.message ?? "Thumbnail upload failed"); return; } } if (status === "scheduled" && startDateValue) { const [h, m] = startTimeValue ? startTimeValue.split(":").map(Number) : [0, 0]; const d = new Date(startDateValue); d.setHours(h, m, 0, 0); const formatted = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( d.getDate(), ).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart( 2, "0", )}:00`; await createArticleSchedule({ id: articleId, date: formatted }); } close(); const publicUrl = `${window.location.protocol}//${window.location.host}/news/detail/${articleId}-${values.slug}`; MySwal.fire({ title: "Saved", icon: "success" }).then(() => { router.push(listHref); successToast("Article URL", publicUrl); }); } finally { close(); } }; const renderPreview = (file: FileWithPreview) => { if (file.type.startsWith("image")) { return ( {file.name} ); } return (
{file.name.slice(0, 8)}…
); }; return (

New {label} article

Tags are used for organization. Categories and creator type are not used for News & Article.

Title

( )} /> {errors.title &&

{errors.title.message}

}

Slug

( )} /> {errors.slug &&

{errors.slug.message}

}

Description

( )} /> {errors.description && (

{errors.description.message}

)} {requireMedia && ( <>

Media files ({label})

Drop files here or click to upload

{contentKind === "image" && "Images: jpg, png, webp…"} {contentKind === "video" && "Video: mp4, webm…"} {contentKind === "audio" && "Audio: mp3, wav…"}

{filesValidation &&

{filesValidation}

} {files.length > 0 && (
{files.map((file, index) => (
{renderPreview(file)}
{file.name}
{file.type.startsWith("image") && ( )}
))}
)} )}

Thumbnail

{requireMedia ? "Optional separate image, or pick a gallery image as thumbnail above." : "Optional cover image for listings."}

{ const f = e.target.files?.[0]; setThumbnailImg(f ? [f] : []); e.target.value = ""; }} /> {(thumbnailImg.length > 0 || (selectedMainImage && files[selectedMainImage - 1])) && (
)} {thumbnailValidation &&

{thumbnailValidation}

}

Tags

(
{value.map((item, index) => ( {item} ))}