qudoco-fe/components/form/article/create-article-form.tsx

498 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<typeof schema>;
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<string, string[]> {
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<FileWithPreview[]>([]);
const [tagInput, setTagInput] = useState("");
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(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<Date | undefined>();
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<FormValues>({
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<string, unknown> = {
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 (
<Image
width={48}
height={48}
alt={file.name}
src={URL.createObjectURL(file)}
className="rounded border p-0.5"
/>
);
}
return (
<div className="flex h-12 w-12 items-center justify-center rounded border bg-slate-100 text-[10px] px-1 text-center">
{file.name.slice(0, 8)}
</div>
);
};
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 shadow-sm">
<p className="text-xs font-medium text-blue-600 uppercase tracking-wide">
New {label} article
</p>
<p className="text-sm text-slate-500 mb-2">
Tags are used for organization. Categories and creator type are not used for News & Article.
</p>
<p className="text-sm">Title</p>
<Controller
control={control}
name="title"
render={({ field }) => (
<Input
placeholder="Article title"
className="h-14 px-4 text-xl"
{...field}
/>
)}
/>
{errors.title && <p className="text-red-500 text-sm">{errors.title.message}</p>}
<p className="text-sm mt-3">Slug</p>
<Controller
control={control}
name="slug"
render={({ field }) => (
<Input className="w-full border rounded-lg" {...field} />
)}
/>
{errors.slug && <p className="text-red-500 text-sm">{errors.slug.message}</p>}
<p className="text-sm mt-3">Description</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<CustomEditor onChange={onChange} initialData={value} />
)}
/>
{errors.description && (
<p className="text-red-500 text-sm">{errors.description.message}</p>
)}
{requireMedia && (
<>
<p className="text-sm mt-4 font-medium">Media files ({label})</p>
<div {...getRootProps({ className: "dropzone cursor-pointer" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border-2 rounded-md py-12 flex flex-col items-center border-slate-200">
<CloudUploadIcon size={48} className="text-slate-300" />
<p className="mt-2 text-slate-700">Drop files here or click to upload</p>
<p className="text-xs text-slate-400 mt-1">
{contentKind === "image" && "Images: jpg, png, webp…"}
{contentKind === "video" && "Video: mp4, webm…"}
{contentKind === "audio" && "Audio: mp3, wav…"}
</p>
</div>
</div>
{filesValidation && <p className="text-red-500 text-sm">{filesValidation}</p>}
{files.length > 0 && (
<div className="space-y-3 mt-2">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex justify-between border px-3 py-3 rounded-md items-center"
>
<div className="flex gap-3 items-center">
{renderPreview(file)}
<div>
<div className="text-sm">{file.name}</div>
{file.type.startsWith("image") && (
<label className="flex items-center gap-2 text-xs mt-1 cursor-pointer">
<input
type="radio"
name="thumb"
checked={selectedMainImage === index + 1}
onChange={() => setSelectedMainImage(index + 1)}
/>
Use as thumbnail
</label>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => {
setFiles((prev) => prev.filter((_, i) => i !== index));
setSelectedMainImage(null);
}}
>
<TimesIcon />
</Button>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setFiles([])}>
Clear all files
</Button>
</div>
)}
</>
)}
</div>
<div className="w-full lg:w-[35%] flex flex-col gap-6">
<div className="bg-white rounded-lg p-6 shadow-sm flex flex-col gap-3">
<p className="text-sm font-medium">Thumbnail</p>
<p className="text-xs text-slate-500">
{requireMedia
? "Optional separate image, or pick a gallery image as thumbnail above."
: "Optional cover image for listings."}
</p>
<input
type="file"
accept="image/*"
className="text-sm"
onChange={(e) => {
const f = e.target.files?.[0];
setThumbnailImg(f ? [f] : []);
e.target.value = "";
}}
/>
{(thumbnailImg.length > 0 || (selectedMainImage && files[selectedMainImage - 1])) && (
<div className="relative w-40">
<img
src={
thumbnailImg[0]
? URL.createObjectURL(thumbnailImg[0])
: URL.createObjectURL(files[selectedMainImage! - 1])
}
alt=""
className="rounded border w-full"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute -top-2 -right-2"
onClick={() => {
setThumbnailImg([]);
setSelectedMainImage(null);
}}
>
×
</Button>
</div>
)}
{thumbnailValidation && <p className="text-red-500 text-xs">{thumbnailValidation}</p>}
<p className="text-sm font-medium pt-2">Tags</p>
<Controller
control={control}
name="tags"
render={({ field: { value } }) => (
<div>
<div className="flex flex-wrap gap-1 mb-2">
{value.map((item, index) => (
<Badge key={`${item}-${index}`} variant="secondary" className="gap-1">
{item}
<button
type="button"
className="text-red-600"
onClick={() => {
const next = value.filter((t) => t !== item);
if (next.length === 0) {
setError("tags", { message: "At least one tag" });
} else {
clearErrors("tags");
setValue("tags", next as [string, ...string[]]);
}
}}
>
×
</button>
</Badge>
))}
</div>
<Textarea
placeholder="Type a tag and press Enter"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const t = tagInput.trim();
if (t) {
setValue("tags", [...value, t]);
setTagInput("");
clearErrors("tags");
}
}
}}
className="min-h-[80px]"
/>
</div>
)}
/>
{errors.tags && <p className="text-red-500 text-sm">{errors.tags.message}</p>}
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="sched"
checked={isScheduled}
onChange={(e) => setIsScheduled(e.target.checked)}
/>
<label htmlFor="sched" className="text-sm">
Schedule publish
</label>
</div>
{isScheduled && (
<div className="flex flex-col gap-2">
<Input
type="date"
onChange={(e) => setStartDateValue(e.target.value ? new Date(e.target.value) : undefined)}
/>
<Input type="time" value={startTimeValue} onChange={(e) => setStartTimeValue(e.target.value)} />
</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="submit"
className="bg-blue-600"
onClick={() => (isScheduled ? setStatus("scheduled") : setStatus("publish"))}
disabled={isScheduled && !startDateValue}
>
{isScheduled ? "Schedule" : "Publish"}
</Button>
<Button type="submit" variant="secondary" onClick={() => setStatus("draft")}>
Save draft
</Button>
<Link href={listHref}>
<Button type="button" variant="outline">
Back
</Button>
</Link>
</div>
</div>
</form>
);
}