fix:article create edit

This commit is contained in:
Rama Priyanto 2025-01-17 17:23:54 +07:00
parent 5080ae17e5
commit db89e73d11
10 changed files with 1259 additions and 474 deletions

View File

@ -4,7 +4,7 @@ import { Card } from "@nextui-org/react";
export default function CreateArticle() { export default function CreateArticle() {
return ( return (
<div className="h-[96vh] bg-transparent p-8 !bg-slate-100 dark:!bg-black"> <div className="h-[96vh] bg-transparent p-8 !bg-slate-100 dark:!bg-black overflow-y-auto">
{/* <FormArticle /> */} {/* <FormArticle /> */}
<CreateArticleForm /> <CreateArticleForm />
</div> </div>

View File

@ -1,10 +1,10 @@
import FormDetailArticle from "@/components/form/form-detail-article"; import EditArticleForm from "@/components/form/article/edit-article-form";
import { Card } from "@nextui-org/react";
export default function DetailArticlePage() { export default function DetailArticlePage() {
return ( return (
<Card className="h-[96vh] rounded-md border bg-transparent"> <div className="h-[96vh] bg-transparent p-8 !bg-slate-100 dark:!bg-black overflow-y-auto">
<FormDetailArticle /> {/* <FormDetailArticle /> */}
</Card> <EditArticleForm isDetail={true} />
</div>
); );
} }

View File

@ -1,10 +1,9 @@
import FormUpdateArticle from '@/components/form/form-edit-article' import EditArticleForm from "@/components/form/article/edit-article-form";
import { Card } from '@nextui-org/react'
export default function UpdateArticlePage() { export default function UpdateArticlePage() {
return ( return (
<Card className="h-[96vh] rounded-md my- ml-3 border bg-transparent"> <div className="h-[96vh] bg-transparent p-8 !bg-slate-100 dark:!bg-black overflow-y-auto">
<FormUpdateArticle /> <EditArticleForm isDetail={false} />
</Card> </div>
) );
} }

View File

@ -13,10 +13,15 @@ import { Button } from "@nextui-org/button";
import { CloudUploadIcon, TimesIcon } from "@/components/icons"; import { CloudUploadIcon, TimesIcon } from "@/components/icons";
import Image from "next/image"; import Image from "next/image";
import { Switch } from "@nextui-org/switch"; import { Switch } from "@nextui-org/switch";
import { getArticleByCategory } from "@/service/article"; import { createArticle, getArticleByCategory } from "@/service/article";
import ReactSelect from "react-select"; import ReactSelect from "react-select";
import makeAnimated from "react-select/animated"; import makeAnimated from "react-select/animated";
import { Chip } from "@nextui-org/react"; import { Chip } from "@nextui-org/react";
import GenerateSingleArticleForm from "./generate-ai-single-form";
import { htmlToString } from "@/utils/global";
import { close, error, loading } from "@/config/swal";
import { useRouter } from "next/navigation";
import Link from "next/link";
// const CustomEditor = dynamic( // const CustomEditor = dynamic(
// () => { // () => {
@ -47,9 +52,6 @@ const createArticleSchema = z.object({
slug: z.string().min(2, { slug: z.string().min(2, {
message: "Slug harus diisi", message: "Slug harus diisi",
}), }),
categoryId: z.string().min(2, {
message: "Pilih Kategori",
}),
description: z.string().min(2, { description: z.string().min(2, {
message: "Deskripsi harus diisi", message: "Deskripsi harus diisi",
}), }),
@ -64,6 +66,7 @@ const createArticleSchema = z.object({
export default function CreateArticleForm() { export default function CreateArticleForm() {
const animatedComponents = makeAnimated(); const animatedComponents = makeAnimated();
const MySwal = withReactContent(Swal); const MySwal = withReactContent(Swal);
const router = useRouter();
const editor = useRef(null); const editor = useRef(null);
const [files, setFiles] = useState<FileWithPreview[]>([]); const [files, setFiles] = useState<FileWithPreview[]>([]);
const [useAi, setUseAI] = useState(false); const [useAi, setUseAI] = useState(false);
@ -132,10 +135,50 @@ export default function CreateArticleForm() {
}); });
}; };
function removeImgTags(htmlString: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(String(htmlString), "text/html");
const images = doc.querySelectorAll("img");
images.forEach((img) => img.remove());
return doc.body.innerHTML;
}
const save = async (values: z.infer<typeof createArticleSchema>) => { const save = async (values: z.infer<typeof createArticleSchema>) => {
console.log("values"); loading();
const formData = {
title: values.title,
typeId: 1,
slug: values.slug,
categoryId: values.category.map((item) => item.id).join(","),
tags: values.tags.join(","),
description: htmlToString(removeImgTags(values.description)),
htmlDescription: removeImgTags(values.description),
}; };
const response = await createArticle(formData);
if (response?.error) {
error(response.message);
return false;
}
close();
successSubmit("/admin/article");
};
function successSubmit(redirect: string) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
const watchTitle = watch("title"); const watchTitle = watch("title");
const generateSlug = (title: string) => { const generateSlug = (title: string) => {
return title return title
@ -219,7 +262,7 @@ export default function CreateArticleForm() {
value={value} value={value}
onChange={onChange} onChange={onChange}
labelPlacement="outside" labelPlacement="outside"
className="w-full mb-3" className="w-full "
classNames={{ classNames={{
inputWrapper: [ inputWrapper: [
"border-1 rounded-lg", "border-1 rounded-lg",
@ -231,9 +274,9 @@ export default function CreateArticleForm() {
)} )}
/> />
{errors?.title && ( {errors?.title && (
<p className="text-red-400 text-sm">{errors.title?.message}</p> <p className="text-red-400 text-sm mb-3">{errors.title?.message}</p>
)} )}
<p className="text-sm">Slug</p> <p className="text-sm mt-3">Slug</p>
<Controller <Controller
control={control} control={control}
name="slug" name="slug"
@ -247,7 +290,7 @@ export default function CreateArticleForm() {
isReadOnly isReadOnly
onChange={onChange} onChange={onChange}
labelPlacement="outside" labelPlacement="outside"
className="w-full mb-3" className="w-full "
classNames={{ classNames={{
inputWrapper: [ inputWrapper: [
"border-1 rounded-lg", "border-1 rounded-lg",
@ -259,14 +302,20 @@ export default function CreateArticleForm() {
)} )}
/> />
{errors?.slug && ( {errors?.slug && (
<p className="text-red-400 text-sm">{errors.slug?.message}</p> <p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
)} )}
<Switch isSelected={useAi} onValueChange={setUseAI}> <Switch isSelected={useAi} onValueChange={setUseAI} className="mt-3">
<p className="text-sm text-black">Bantuan AI</p> <p className="text-sm text-black">Bantuan AI</p>
</Switch> </Switch>
<p className="text-sm">Deskripsi</p> {useAi && (
<GenerateSingleArticleForm
content={(data) => setValue("description", data)}
/>
)}
<p className="text-sm mt-3">Deskripsi</p>
<Controller <Controller
control={control} control={control}
name="description" name="description"
@ -276,19 +325,23 @@ export default function CreateArticleForm() {
ref={editor} ref={editor}
value={value} value={value}
onChange={onChange} onChange={onChange}
className="dark:text-black mb-3" className="dark:text-black"
/> />
)} )}
/> />
{errors?.description && (
<p className="text-red-400 text-sm mb-3">
{errors.description?.message}
</p>
)}
<p className="text-sm">File Media</p> <p className="text-sm mt-3">File Media</p>
<Fragment> <Fragment>
<div {...getRootProps({ className: "dropzone" })}> <div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} /> <input {...getInputProps()} />
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col"> <div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon size={50} className="text-gray-300" /> <CloudUploadIcon size={50} className="text-gray-300" />
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80"> <h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
{/* Drop files here or click to upload. */}
Tarik file disini atau klik untuk upload. Tarik file disini atau klik untuk upload.
</h4> </h4>
<div className=" text-xs text-muted-foreground"> <div className=" text-xs text-muted-foreground">
@ -313,12 +366,13 @@ export default function CreateArticleForm() {
) : null} ) : null}
</Fragment> </Fragment>
</div> </div>
<div className="w-[35%] h-fit bg-white rounded-lg p-8 flex flex-col gap-1"> <div className="w-[35%] flex flex-col gap-8">
<div className="h-fit bg-white rounded-lg p-8 flex flex-col gap-1">
<p className="text-sm">Thubmnail</p> <p className="text-sm">Thubmnail</p>
<Button className="w-fit mb-3" color="primary" size="sm"> <Button className="w-fit" color="primary" size="sm">
Upload Thumbnail Upload Thumbnail
</Button> </Button>
<p className="text-sm">Kategori</p> <p className="text-sm mt-3">Kategori</p>
<Controller <Controller
control={control} control={control}
name="category" name="category"
@ -327,7 +381,7 @@ export default function CreateArticleForm() {
className="basic-single text-black z-50" className="basic-single text-black z-50"
classNames={{ classNames={{
control: (state: any) => control: (state: any) =>
"!rounded-xl bg-white !border-1 !border-gray-200", "!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}} }}
classNamePrefix="select" classNamePrefix="select"
onChange={onChange} onChange={onChange}
@ -343,7 +397,9 @@ export default function CreateArticleForm() {
)} )}
/> />
{errors?.category && ( {errors?.category && (
<p className="text-red-400 text-sm">{errors.category?.message}</p> <p className="text-red-400 text-sm mb-3">
{errors.category?.message}
</p>
)} )}
<p className="text-sm">Tags</p> <p className="text-sm">Tags</p>
@ -399,7 +455,7 @@ export default function CreateArticleForm() {
} }
}} }}
labelPlacement="outside" labelPlacement="outside"
className="w-full mb-3 h-fit" className="w-full h-fit"
classNames={{ classNames={{
inputWrapper: [ inputWrapper: [
"border-1 rounded-lg", "border-1 rounded-lg",
@ -411,9 +467,23 @@ export default function CreateArticleForm() {
)} )}
/> />
{errors?.tags && ( {errors?.tags && (
<p className="text-red-400 text-sm">{errors.tags?.message}</p> <p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
)} )}
</div> </div>
<div className="flex flex-row justify-end gap-3">
<Button color="primary" type="submit">
Publish
</Button>
<Button color="success" type="button">
<p className="text-white">Draft</p>
</Button>
<Link href="/admin/article">
<Button color="danger" variant="bordered" type="button">
Kembali
</Button>
</Link>
</div>
</div>
</form> </form>
); );
} }

View File

@ -0,0 +1,526 @@
"use client";
import { FormEvent, 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 { Input, Textarea } from "@nextui-org/input";
import dynamic from "next/dynamic";
import JoditEditor from "jodit-react";
import { useDropzone } from "react-dropzone";
import { Button } from "@nextui-org/button";
import { CloudUploadIcon, TimesIcon } from "@/components/icons";
import Image from "next/image";
import { Switch } from "@nextui-org/switch";
import {
createArticle,
getArticleByCategory,
getArticleById,
updateArticle,
} from "@/service/article";
import ReactSelect from "react-select";
import makeAnimated from "react-select/animated";
import { Chip } from "@nextui-org/react";
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 { list } from "postcss";
import GetSeoScore from "./get-seo-score-form";
import Link from "next/link";
// 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",
}),
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",
}), // Array berisi string
});
export default function EditArticleForm(props: { isDetail: boolean }) {
const { isDetail } = props;
const params = useParams();
const id = params?.id;
const animatedComponents = makeAnimated();
const MySwal = withReactContent(Swal);
const router = useRouter();
const editor = useRef(null);
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [useAi, setUseAI] = useState(false);
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [tag, setTag] = useState("");
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
});
const formOptions = {
resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", category: [], tags: [] },
};
type UserSettingSchema = z.infer<typeof createArticleSchema>;
const {
register,
control,
handleSubmit,
formState: { errors },
setValue,
getValues,
watch,
setError,
clearErrors,
} = useForm<UserSettingSchema>(formOptions);
useEffect(() => {
async function initState() {
const res = await getArticleById(id);
// setArticle(data);
const data = res.data?.data;
setValue("title", data?.title);
// setTypeId(String(data?.typeId));
setValue("slug", data?.slug);
setValue("description", data?.htmlDescription);
setValue("tags", data?.tags ? data.tags.split(",") : []);
setupInitCategory([data?.categoryId]);
console.log("Data Aritcle", data);
}
initState();
}, [listCategory]);
const setupInitCategory = (data: number[]) => {
const temp: CategoryType[] = [];
for (let i = 0; i < data.length; i++) {
const datas = listCategory.filter((a) => a.id == data[i]);
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<typeof createArticleSchema>) => {
MySwal.fire({
title: "Simpan Data",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(values);
}
});
};
const save = async (values: z.infer<typeof createArticleSchema>) => {
loading();
const formData = {
// id: Number(id),
title: values.title,
typeId: 1,
slug: values.slug,
categoryId: values.category[0].id,
tags: values.tags.join(","),
description: htmlToString(values.description),
htmlDescription: values.description,
};
const response = await updateArticle(String(id), formData);
if (response?.error) {
error(response.message);
return false;
}
close();
successSubmit("/admin/article");
};
function successSubmit(redirect: string) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
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 (
<Image
width={48}
height={48}
alt={file.name}
src={URL.createObjectURL(file)}
className=" rounded border p-0.5"
/>
);
} 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) => (
<div
key={file.name}
className=" flex justify-between border px-3.5 py-3 my-6 rounded-md"
>
<div className="flex gap-3 items-center">
<div className="file-preview">{renderFilePreview(file)}</div>
<div>
<div className=" text-sm text-card-foreground">{file.name}</div>
<div className=" text-xs font-light text-muted-foreground">
{Math.round(file.size / 100) / 10 > 1000 ? (
<>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
) : (
<>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
)}
{" kb"}
</div>
</div>
</div>
<Button
className=" border-none rounded-full"
onClick={() => handleRemoveFile(file)}
>
<TimesIcon />
</Button>
</div>
));
return (
<form
className="flex flex-row gap-8 text-black"
onSubmit={handleSubmit(onSubmit)}
>
<div className="w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1">
{isDetail && <GetSeoScore id="1961" />}
<p className="text-sm">Judul</p>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<Input
type="text"
id="title"
placeholder=""
label=""
value={value}
isReadOnly={isDetail}
onChange={onChange}
labelPlacement="outside"
className="w-full "
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
variant="bordered"
/>
)}
/>
{errors?.title && (
<p className="text-red-400 text-sm mb-3">{errors.title?.message}</p>
)}
<p className="text-sm mt-3">Slug</p>
<Controller
control={control}
name="slug"
render={({ field: { onChange, value } }) => (
<Input
type="text"
id="title"
placeholder=""
label=""
value={value}
isReadOnly
onChange={onChange}
labelPlacement="outside"
className="w-full "
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
variant="bordered"
/>
)}
/>
{errors?.slug && (
<p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
)}
{/* <Switch isSelected={useAi} onValueChange={setUseAI} className="mt-3">
<p className="text-sm text-black">Bantuan AI</p>
</Switch> */}
{useAi && (
<GenerateSingleArticleForm
content={(data) => setValue("description", data)}
/>
)}
<p className="text-sm mt-3">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
// <CustomEditor onChange={onChange} initialData={value} />
<JoditEditor
ref={editor}
value={value}
onChange={onChange}
className="dark:text-black"
/>
)}
/>
{errors?.description && (
<p className="text-red-400 text-sm mb-3">
{errors.description?.message}
</p>
)}
<p className="text-sm mt-3">File Media</p>
<Fragment>
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
<CloudUploadIcon size={50} className="text-gray-300" />
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
Tarik file disini atau klik untuk upload.
</h4>
<div className=" text-xs text-muted-foreground">
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
maksimal 100mb.)
</div>
</div>
</div>
{files.length ? (
<Fragment>
<div>{fileList}</div>
<div className=" flex justify-between gap-2">
{/* <div className="flex flex-row items-center gap-3 py-3">
<Label>Gunakan Watermark</Label>
<div className="flex items-center gap-3">
<Switch defaultChecked color="primary" id="c2" />
</div>
</div> */}
<Button onPress={() => setFiles([])}>Remove All</Button>
</div>
</Fragment>
) : null}
</Fragment>
</div>
<div className="w-[35%] flex flex-col gap-8">
<div className="h-fit bg-white rounded-lg p-8 flex flex-col gap-1">
<p className="text-sm">Thubmnail</p>
<Button className="w-fit" color="primary" size="sm">
Upload Thumbnail
</Button>
<p className="text-sm mt-3">Kategori</p>
<Controller
control={control}
name="category"
render={({ field: { onChange, value } }) => (
<ReactSelect
className="basic-single text-black z-50"
classNames={{
control: (state: any) =>
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
}}
classNamePrefix="select"
value={value}
onChange={onChange}
closeMenuOnSelect={false}
components={animatedComponents}
isClearable={true}
isSearchable={true}
isMulti={true}
placeholder="Kategori..."
name="sub-module"
options={listCategory}
/>
)}
/>
{errors?.category && (
<p className="text-red-400 text-sm mb-3">
{errors.category?.message}
</p>
)}
<p className="text-sm">Tags</p>
<Controller
control={control}
name="tags"
render={({ field: { onChange, value } }) => (
<Textarea
type="text"
id="tags"
placeholder=""
label=""
value={tag}
onValueChange={setTag}
startContent={
<div className="flex flex-wrap gap-1">
{value.map((item, index) => (
<Chip
color="primary"
key={index}
className=""
onClose={() => {
const filteredTags = value.filter(
(tag) => tag !== item
);
if (filteredTags.length === 0) {
setError("tags", {
type: "manual",
message: "Tags tidak boleh kosong",
});
} else {
clearErrors("tags");
setValue(
"tags",
filteredTags as [string, ...string[]]
);
}
}}
>
{item}
</Chip>
))}
</div>
}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (tag.trim() !== "") {
setValue("tags", [...value, tag.trim()]);
setTag("");
e.preventDefault();
}
}
}}
labelPlacement="outside"
className="w-full h-fit"
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
variant="bordered"
/>
)}
/>
{errors?.tags && (
<p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
)}
</div>
<div className="flex flex-row justify-end gap-3">
{!isDetail && (
<Button color="primary" type="submit">
Publish
</Button>
)}
{!isDetail && (
<Button color="success" type="button">
<p className="text-white">Draft</p>
</Button>
)}
<Link href="/admin/article">
<Button color="danger" variant="bordered" type="button">
Kembali
</Button>
</Link>
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,396 @@
"use client";
import {
Button,
Input,
Select,
SelectItem,
SelectSection,
} from "@nextui-org/react";
import { FormEvent, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { close, error, loading } from "@/config/swal";
import {
generateDataArticle,
getDetailArticle,
getGenerateKeywords,
getGenerateTitle,
} from "@/service/generate-article";
import { delay } from "@/utils/global";
const writingStyle = [
{
id: 1,
name: "Friendly",
},
{
id: 1,
name: "Professional",
},
{
id: 3,
name: "Informational",
},
{
id: 4,
name: "Neutral",
},
{
id: 5,
name: "Witty",
},
];
const articleSize = [
{
id: 1,
name: "News (300 - 900 words)",
value: "News",
},
{
id: 2,
name: "Info (900 - 2000 words)",
value: "Info",
},
{
id: 3,
name: "Detail (2000 - 5000 words)",
value: "Detail",
},
];
export default function GenerateSingleArticleForm(props: {
content: (data: string) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] =
useState("Informational");
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
const [selectedLanguage, setSelectedLanguage] = useState("id");
const [mainKeyword, setMainKeyword] = useState("");
const [title, setTitle] = useState("");
const [additionalKeyword, setAdditionalKeyword] = useState("");
const [articleIds, setArticleIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number>();
const [isLoading, setIsLoading] = useState(true);
const generateAll = async (keyword: string | undefined) => {
if (keyword) {
generateTitle(keyword);
generateKeywords(keyword);
}
};
const generateTitle = async (keyword: string | undefined) => {
if (keyword) {
loading();
const req = {
keyword: keyword,
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "None",
clientId: "",
};
const res = await getGenerateTitle(req);
const data = res?.data?.data;
setTitle(data);
close();
}
};
const generateKeywords = async (keyword: string | undefined) => {
if (keyword) {
const req = {
keyword: keyword,
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "0",
clientId: "",
};
loading();
const res = await getGenerateKeywords(req);
const data = res?.data?.data;
setAdditionalKeyword(data);
close();
}
};
const onSubmit = async () => {
loading();
const request = {
advConfig: "",
style: selectedWritingSyle,
website: "None",
connectToWeb: true,
lang: selectedLanguage,
pointOfView: "None",
title: title,
imageSource: "Web",
mainKeyword: mainKeyword,
additionalKeywords: additionalKeyword,
targetCountry: null,
articleSize: selectedArticleSize,
projectId: 2,
createdBy: "123123",
clientId: "humasClientIdtest",
};
console.log("reqq", request);
const res = await generateDataArticle(request);
close();
console.log("res", res?.data?.data);
if (res?.error) {
error("Error");
}
setArticleIds([...articleIds, res?.data?.data?.id]);
// props.articleId(res?.data?.data?.id);
};
useEffect(() => {
getArticleDetail();
}, [selectedId]);
const checkArticleStatus = async (data: string | null) => {
if (data === null) {
delay(7000).then(() => {
getArticleDetail();
});
}
};
const getArticleDetail = async () => {
if (selectedId) {
const res = await getDetailArticle(selectedId);
const data = res?.data?.data?.articleBody;
checkArticleStatus(data);
if (data !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content("");
}
}
};
return (
<fieldset>
<form className="flex flex-col w-full mt-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
<Select
label="Writing Style"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedWritingSyle]}
onChange={(e) =>
e.target.value !== ""
? setSelectedWritingStyle(e.target.value)
: ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
{writingStyle.map((style) => (
<SelectItem key={style.name}>{style.name}</SelectItem>
))}
</SelectSection>
</Select>
<Select
label="Article Size"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedArticleSize]}
onChange={(e) =>
e.target.value !== ""
? setSelectedArticleSize(e.target.value)
: ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
{articleSize.map((size) => (
<SelectItem key={size.value}>{size.name}</SelectItem>
))}
</SelectSection>
</Select>
<Select
label="Bahasa"
variant="bordered"
labelPlacement="outside"
placeholder=""
selectedKeys={[selectedLanguage]}
onChange={(e) =>
e.target.value !== "" ? setSelectedLanguage(e.target.value) : ""
}
className="w-full"
classNames={{
label: "!text-black",
value: "!text-black",
trigger: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
>
<SelectSection>
<SelectItem key="id">Indonesia</SelectItem>
<SelectItem key="en">English</SelectItem>
</SelectSection>
</Select>
</div>
<div className="flex flex-col mt-3">
<div className="flex flex-row gap-2 items-center">
<p className="text-sm">Main Keyword</p>
<Button
color="primary"
size="sm"
onPress={() => generateAll(mainKeyword)}
>
Process
</Button>
</div>
<Input
type="text"
id="mainKeyword"
placeholder=""
label=""
value={mainKeyword}
onValueChange={setMainKeyword}
labelPlacement="outside"
className="w-full mt-1"
variant="bordered"
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
/>
{mainKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)}
<div className="flex flex-row gap-2 items-center mt-3">
<p className="text-sm">Title</p>
<Button
color="primary"
size="sm"
onPress={() => generateTitle(mainKeyword)}
isDisabled={mainKeyword == ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="title"
placeholder=""
label=""
value={title}
onValueChange={setTitle}
labelPlacement="outside"
className="w-full mt-1"
variant="bordered"
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
/>
{/* {title == "" && <p className="text-red-400 text-sm">Required</p>} */}
<div className="flex flex-row gap-2 items-center mt-2">
<p className="text-sm">Additional Keyword</p>
<Button
color="primary"
size="sm"
onPress={() => generateKeywords(mainKeyword)}
isDisabled={mainKeyword == ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="additionalKeyword"
placeholder=""
label=""
value={additionalKeyword}
onValueChange={setAdditionalKeyword}
labelPlacement="outside"
className="w-full mt-1"
variant="bordered"
classNames={{
inputWrapper: [
"border-1 rounded-lg",
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
],
}}
/>
{/* {additionalKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)} */}
<Button
color="primary"
className="my-5 w-full py-5 text-xs md:text-base"
type="button"
onPress={onSubmit}
isDisabled={
mainKeyword == "" || title == "" || additionalKeyword == ""
}
>
Generate
</Button>
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1">
{articleIds?.map((id) => (
<Button
onPress={() => setSelectedId(id)}
key={id}
isLoading={isLoading && selectedId == id}
color={
selectedId == id && isLoading
? "warning"
: selectedId == id
? "success"
: "default"
}
>
<p className={selectedId == id ? "text-white" : "text-black"}>
{id}
</p>
</Button>
))}
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import { error } from "@/config/swal";
import { getSeoScore } from "@/service/generate-article";
import { Accordion, AccordionItem, CircularProgress } from "@nextui-org/react";
import { useEffect, useRef, useState } from "react";
export default function GetSeoScore(props: { id: string }) {
useEffect(() => {
fetchSeoScore();
}, []);
const [totalScoreSEO, setTotalScoreSEO] = useState();
const [errorSEO, setErrorSEO] = useState<any>([]);
const [warningSEO, setWarningSEO] = useState<any>([]);
const [optimizedSEO, setOptimizedSEO] = useState<any>([]);
const fetchSeoScore = async () => {
const res = await getSeoScore("1931");
if (res.error) {
error(res.message);
return false;
}
setTotalScoreSEO(res.data.data?.seo_analysis?.score || 0);
let errorList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.error,
...res.data.data?.seo_analysis?.analysis?.content_quality?.error,
];
setErrorSEO(errorList);
let warningList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.warning,
...res.data.data?.seo_analysis?.analysis?.content_quality?.warning,
];
setWarningSEO(warningList);
let optimizedList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.optimized,
...res.data.data?.seo_analysis?.analysis?.content_quality?.optimized,
];
setOptimizedSEO(optimizedList);
};
return (
<div className="overflow-y-auto my-2">
<div className="text-black flex flex-col rounded-md gap-3">
<p className="font-semibold text-lg"> SEO Score</p>
<div className="flex flex-row gap-5 w-full">
<CircularProgress
aria-label=""
color="warning"
showValueLabel={true}
size="lg"
value={Number(totalScoreSEO) * 100}
/>
<div>
{/* <ApexChartDonut value={Number(totalScoreSEO) * 100} /> */}
</div>
<div className="flex flex-row gap-5">
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-red-500 rounded-lg">
{/* <TimesIcon size={15} className="text-danger" /> */}
Error : {errorSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-yellow-500 rounded-lg">
{/* <p className="text-warning w-[15px] h-[15px] text-center mt-[-10px]">
!
</p> */}
Warning : {warningSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-green-500 rounded-lg">
{/* <CheckIcon size={15} className="text-success" /> */}
Optimize : {optimizedSEO.length || 0}
</div>
</div>
</div>
<Accordion
variant="splitted"
itemClasses={{
base: "!bg-transparent",
title: "text-black",
}}
>
<AccordionItem
key="1"
aria-label="Error"
// startContent={<TimesIcon size={20} className="text-danger" />}
title={`${errorSEO?.length || 0} Errors`}
>
<div className="flex flex-col gap-2">
{errorSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
<AccordionItem
key="2"
aria-label="Warning"
// startContent={
// <p className="text-warning w-[20px] h-[20px] text-center mt-[-10px]">
// !
// </p>
// }
title={`${warningSEO?.length || 0} Warnings`}
>
<div className="flex flex-col gap-2">
{warningSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
<AccordionItem
key="3"
aria-label="Optimized"
// startContent={<CheckIcon size={20} className="text-success" />}
title={`${optimizedSEO?.length || 0} Optimized`}
>
<div className="flex flex-col gap-2">
{optimizedSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
</Accordion>
</div>
</div>
);
}

View File

@ -1,115 +1,14 @@
"use client"; "use client";
import { error } from "@/config/swal"; import { error } from "@/config/swal";
import { getArticleById } from "@/service/article";
import { getSeoScore } from "@/service/generate-article"; import { getSeoScore } from "@/service/generate-article";
import { zodResolver } from "@hookform/resolvers/zod"; import { Accordion, AccordionItem, CircularProgress } from "@nextui-org/react";
import {
Accordion,
AccordionItem,
Button,
Card,
Chip,
CircularProgress,
Input,
Select,
SelectItem,
} from "@nextui-org/react";
import JoditEditor from "jodit-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import * as z from "zod";
import { TimesIcon } from "../icons";
const articleSchema = z.object({ import EditArticleForm from "./article/edit-article-form";
title: z.string().min(1, { message: "Required" }),
article: z.string().min(1, { message: "Required" }),
slug: z.string().min(1, { message: "Required" }),
tags: z.string().min(0, { message: "Required" }).optional(),
description: z.string().min(1, { message: "Required" }).optional(),
});
const TypeId = [
{
key: "1",
label: "Article",
},
{
key: "2",
label: "Magazine",
},
];
export default function FormDetailArticle() { export default function FormDetailArticle() {
// const [id, setId] = useState<any>();
const [title, setTitle] = useState<string>("");
const [slug, setSlug] = useState<string>("");
const [tags, setTags] = useState<string[]>([]);
const [newTags, setNewTags] = useState<string>("");
const editor = useRef(null);
const [content, setContent] = useState("");
const MySwal = withReactContent(Swal);
const [article, setArticle] = useState<any>();
const [typeArticle, setTypeArticle] = useState("");
const pathname = usePathname();
const splitPathname = pathname.split("/");
const id = splitPathname[splitPathname.length - 1];
console.log(id, "pathnamesplit");
const formOptions = { resolver: zodResolver(articleSchema) };
type MicroIssueSchema = z.infer<typeof articleSchema>;
const {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<MicroIssueSchema>(formOptions);
const editorConfig = {
readonly: true,
};
const handleClose = (tagsToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagsToRemove));
if (tags.length === 1) {
setTags([]);
}
};
const handleAddTags = (e: any) => {
if (newTags.trim() !== "") {
setTags([...tags, newTags.trim()]);
setNewTags("");
e.preventDefault();
}
};
const handleKeyDown = (event: any) => {
if (event.key === "Enter") {
handleAddTags(event);
}
};
useEffect(() => { useEffect(() => {
async function initState() {
const res = await getArticleById(id);
setArticle(res.data?.data);
setTitle(res.data?.data?.title);
setTypeArticle(String(res.data.data?.typeId));
console.log("ii", String(res.data.data?.typeId));
const tagsArray = res.data.data?.tags
? res.data.data.tags.split(",")
: [];
setTags(tagsArray);
console.log("Data Aritcle", tagsArray);
}
initState();
fetchSeoScore(); fetchSeoScore();
}, []); }, []);
@ -142,252 +41,9 @@ export default function FormDetailArticle() {
setOptimizedSEO(optimizedList); setOptimizedSEO(optimizedList);
}; };
async function save(data: any) {
const formData = {
id: id,
title: title,
typeId: parseInt(String(Array.from(article)[0])),
slug: slug,
tags: tags.join(","),
description: content,
htmlDescription: content,
};
console.log("Form Data:", formData);
// const response = await createArticle(formData);
// if (response?.error) {
// error(response.message);
// return false;
// }
}
async function onSubmit(data: any) {
MySwal.fire({
title: "Simpan Data",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(data);
}
});
}
return ( return (
<div className="mx-5 my-5 overflow-y-auto"> <div className="mx-5 my-5 overflow-y-auto">
<div className="text-black px-3 flex flex-col rounded-md gap-3"> <EditArticleForm isDetail={true} />
<p className="font-semibold text-lg"> SEO Score</p>
<div className="flex flex-row gap-5 w-full">
<CircularProgress
aria-label=""
color="warning"
showValueLabel={true}
size="lg"
value={Number(totalScoreSEO) * 100}
/>
<div>
{/* <ApexChartDonut value={Number(totalScoreSEO) * 100} /> */}
</div>
<div className="flex flex-row gap-5">
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-red-500 rounded-lg">
{/* <TimesIcon size={15} className="text-danger" /> */}
Error : {errorSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-yellow-500 rounded-lg">
{/* <p className="text-warning w-[15px] h-[15px] text-center mt-[-10px]">
!
</p> */}
Warning : {warningSEO.length || 0}
</div>
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-green-500 rounded-lg">
{/* <CheckIcon size={15} className="text-success" /> */}
Optimize : {optimizedSEO.length || 0}
</div>
</div>
</div>
<Accordion
variant="splitted"
itemClasses={{
base: "!bg-transparent",
title: "text-black",
}}
>
<AccordionItem
key="1"
aria-label="Error"
// startContent={<TimesIcon size={20} className="text-danger" />}
title={`${errorSEO?.length || 0} Errors`}
>
<div className="flex flex-col gap-2">
{errorSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
<AccordionItem
key="2"
aria-label="Warning"
// startContent={
// <p className="text-warning w-[20px] h-[20px] text-center mt-[-10px]">
// !
// </p>
// }
title={`${warningSEO?.length || 0} Warnings`}
>
<div className="flex flex-col gap-2">
{warningSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
<AccordionItem
key="3"
aria-label="Optimized"
// startContent={<CheckIcon size={20} className="text-success" />}
title={`${optimizedSEO?.length || 0} Optimized`}
>
<div className="flex flex-col gap-2">
{optimizedSEO?.map((item: any) => (
<p
key={item}
className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
>
{item}
</p>
))}
</div>
</AccordionItem>
</Accordion>
</div>
<form method="POST" onSubmit={handleSubmit(onSubmit)}>
<Card className="rounded-md p-5 space-y-5">
<div>
<Input
type="title"
{...register("title")}
isReadOnly
value={title}
onValueChange={setTitle}
label="Judul"
variant="bordered"
placeholder="Enter Text"
labelPlacement="outside"
/>
<div className="text-sm text-red-500">
{title.length === 0 && errors.title && errors.title.message}
</div>
</div>
<div>
<Select
label="Jenis Artikel"
{...register("article")}
variant="bordered"
labelPlacement="outside"
placeholder="Select"
selectedKeys={[typeArticle]}
className="max-w-xs"
onChange={(e) => setTypeArticle(e.target.value)}
>
{TypeId.map((data) => (
<SelectItem key={data.key} value={data.key}>
{data.label}
</SelectItem>
))}
</Select>
<div className="text-sm text-red-500">
{errors.article?.message}
</div>
{/* <p>{article}</p> */}
</div>
<div>
<Input
isReadOnly
type="text"
{...register("slug")}
value={article?.slug}
onChange={(e) => setSlug(e.target.value)}
label="Slug"
variant="bordered"
placeholder="Enter Text"
labelPlacement="outside"
/>
<div className="text-sm text-red-500">
{slug.length === 0 && errors.slug && errors.slug.message}
</div>
</div>
<div>
<p className="text-sm">Tags</p>
{/* <Input
label="Tags (Optional)"
{...register("tags")}
labelPlacement='outside'
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Tambahkan tag baru dan tekan Enter"
/> */}
<div className="text-sm text-red-500">
{tags.length === 0 && errors.tags && errors.tags.message}
</div>
<div className="flex gap-2 border border-inherit mt-2 rounded-md p-1 items-center h-11">
{tags.map((tag, index) => (
<Chip
key={index}
color="primary"
onClose={() => handleClose("")}
>
{tag}
</Chip>
))}
</div>
</div>
<div>
<p className="pb-2">Description</p>
<JoditEditor
ref={editor}
value={article?.description}
// config={editorConfig}
onChange={(newContent) => setContent(newContent)}
className="dark:text-black"
/>
<div className="text-sm text-red-500">
{content.length === 0 &&
errors.description &&
errors.description.message}
</div>
</div>
<div className="flex justify-end gap-3">
<Link href={`/admin/master-role`}>
<Button color="danger" variant="ghost">
Cancel
</Button>
</Link>
<Button
// type="submit"
color="primary"
variant="solid"
>
Publish
</Button>
</div>
</Card>
</form>
</div> </div>
); );
} }

View File

@ -52,7 +52,6 @@ export default function FormUpdateArticle() {
const pathname = usePathname(); const pathname = usePathname();
const splitPathname = pathname.split("/"); const splitPathname = pathname.split("/");
const id = splitPathname[splitPathname.length - 1]; const id = splitPathname[splitPathname.length - 1];
console.log(id, "pathnamesplit");
const formOptions = { resolver: zodResolver(articleSchema) }; const formOptions = { resolver: zodResolver(articleSchema) };
type MicroIssueSchema = z.infer<typeof articleSchema>; type MicroIssueSchema = z.infer<typeof articleSchema>;

View File

@ -30,12 +30,12 @@ export async function createArticle(data: any) {
return await httpPost(pathUrl, headers, data); return await httpPost(pathUrl, headers, data);
} }
export async function updateArticle(id: any) { export async function updateArticle(id: string, data: any) {
const headers = { const headers = {
"content-type": "application/json", "content-type": "application/json",
}; };
const pathUrl = `/articles/${id}`; const pathUrl = `/articles/${id}`;
return await httpPut(pathUrl, headers); return await httpPut(pathUrl, headers, data);
} }
export async function getArticleById(id: any) { export async function getArticleById(id: any) {