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

498 lines
17 KiB
TypeScript
Raw Normal View History

"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>
);
}