498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|