1213 lines
38 KiB
TypeScript
1213 lines
38 KiB
TypeScript
"use client";
|
||
import React, { ChangeEvent, useEffect, useRef, useState } from "react";
|
||
import { useForm, Controller } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import * as z from "zod";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useTranslations } from "next-intl";
|
||
import dynamic from "next/dynamic";
|
||
import Cookies from "js-cookie";
|
||
|
||
// UI Components
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||
|
||
// Icons
|
||
import {
|
||
AlertCircle,
|
||
FileText,
|
||
Image,
|
||
Loader2,
|
||
Save,
|
||
Trash2,
|
||
Edit3,
|
||
Globe,
|
||
Users,
|
||
Tag,
|
||
Eye,
|
||
Settings,
|
||
CheckCircle,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
|
||
// Swiper
|
||
import { Swiper, SwiperSlide } from "swiper/react";
|
||
import "swiper/css";
|
||
import "swiper/css/free-mode";
|
||
import "swiper/css/navigation";
|
||
import "swiper/css/pagination";
|
||
import "swiper/css/thumbs";
|
||
import { FreeMode, Navigation, Pagination, Thumbs } from "swiper/modules";
|
||
|
||
// Services
|
||
import {
|
||
convertSPIT,
|
||
deleteSPIT,
|
||
detailSPIT,
|
||
getTagsBySubCategoryId,
|
||
listEnableCategory,
|
||
} from "@/service/content/content";
|
||
import { generateDataRewrite, getDetailArticle } from "@/service/content/ai";
|
||
|
||
// Utils
|
||
import { getCookiesDecrypt } from "@/lib/utils";
|
||
import { error } from "@/lib/swal";
|
||
import Swal from "sweetalert2";
|
||
import withReactContent from "sweetalert2-react-content";
|
||
import { close, loading } from "@/config/swal";
|
||
|
||
// Types
|
||
interface Category {
|
||
id: number;
|
||
name: string;
|
||
}
|
||
|
||
interface Detail {
|
||
id: string;
|
||
contentTitle: string;
|
||
contentDescription: string;
|
||
slug: string;
|
||
content: {
|
||
id: number;
|
||
name: string;
|
||
};
|
||
contentCreator: string;
|
||
creatorName: string;
|
||
contentThumbnail: string;
|
||
contentTag: string;
|
||
categoryId?: number;
|
||
contentList?: FileType[];
|
||
}
|
||
|
||
interface Option {
|
||
id: string;
|
||
label: string;
|
||
}
|
||
|
||
interface FileType {
|
||
contentId: number;
|
||
contentFile: string;
|
||
thumbnailFileUrl: string;
|
||
fileName: string;
|
||
}
|
||
|
||
interface PlacementData {
|
||
mediaFileId: number;
|
||
placements: string;
|
||
}
|
||
|
||
// Schema
|
||
const formSchema = z.object({
|
||
contentTitle: z.string().min(1, { message: "Judul diperlukan" }),
|
||
contentDescription: z.string().optional(),
|
||
contentRewriteDescription: z.string().optional(),
|
||
contentCreator: z.string().min(1, { message: "Creator diperlukan" }),
|
||
});
|
||
|
||
type FormData = z.infer<typeof formSchema>;
|
||
|
||
// Constants
|
||
const PUBLISH_OPTIONS: Option[] = [
|
||
{ id: "all", label: "SEMUA" },
|
||
{ id: "5", label: "UMUM" },
|
||
{ id: "6", label: "JOURNALIS" },
|
||
{ id: "7", label: "POLRI" },
|
||
{ id: "8", label: "KSP" },
|
||
];
|
||
|
||
const WRITING_STYLES = [
|
||
{ value: "friendly", label: "Friendly" },
|
||
{ value: "professional", label: "Profesional" },
|
||
{ value: "informational", label: "Informational" },
|
||
{ value: "neutral", label: "Neutral" },
|
||
{ value: "witty", label: "Witty" },
|
||
];
|
||
|
||
const PLACEMENT_OPTIONS = [
|
||
{ value: "all", label: "Semua" },
|
||
{ value: "mabes", label: "Nasional" },
|
||
{ value: "polda", label: "Wilayah" },
|
||
{ value: "international", label: "Internasional" },
|
||
];
|
||
|
||
// Dynamic imports
|
||
const CustomEditor = dynamic(
|
||
() => import("@/components/editor/custom-editor"),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div className="flex items-center justify-center h-32 border rounded-md bg-muted/50">
|
||
<Loader2 className="h-6 w-6 animate-spin" />
|
||
<span className="ml-2">Loading editor...</span>
|
||
</div>
|
||
),
|
||
}
|
||
);
|
||
|
||
export default function FormConvertSPIT() {
|
||
const MySwal = withReactContent(Swal);
|
||
const router = useRouter();
|
||
const t = useTranslations("Form");
|
||
const { id } = useParams() as { id: string };
|
||
const [isAlreadySaved, setIsAlreadySaved] = useState(false);
|
||
|
||
// Form state
|
||
const {
|
||
control,
|
||
handleSubmit,
|
||
setValue,
|
||
formState: { errors, isSubmitting },
|
||
watch,
|
||
} = useForm<FormData>({
|
||
resolver: zodResolver(formSchema),
|
||
defaultValues: {
|
||
contentTitle: "",
|
||
contentDescription: "",
|
||
contentCreator: "",
|
||
contentRewriteDescription: "",
|
||
},
|
||
});
|
||
|
||
// Component state
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [detail, setDetail] = useState<Detail | null>(null);
|
||
const [categories, setCategories] = useState<Category[]>([]);
|
||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(
|
||
null
|
||
);
|
||
const [selectedFileType, setSelectedFileType] = useState<
|
||
"original" | "rewrite"
|
||
>("original");
|
||
const [selectedWritingStyle, setSelectedWritingStyle] =
|
||
useState("professional");
|
||
const [showRewriteEditor, setShowRewriteEditor] = useState(false);
|
||
const [isGeneratingRewrite, setIsGeneratingRewrite] = useState(false);
|
||
const [isLoadingRewrite, setIsLoadingRewrite] = useState(false);
|
||
const [detailThumb, setDetailThumb] = useState<string[]>([]);
|
||
const [thumbsSwiper, setThumbsSwiper] = useState<any>(null);
|
||
const [files, setFiles] = useState<FileType[]>([]);
|
||
const [filePlacements, setFilePlacements] = useState<string[][]>([]);
|
||
const [articleIds, setArticleIds] = useState<string[]>([]);
|
||
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
|
||
null
|
||
);
|
||
const [articleBody, setArticleBody] = useState<string>("");
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [publishedFor, setPublishedFor] = useState<string[]>([]);
|
||
const [inputRef] = useState(useRef<HTMLInputElement>(null));
|
||
const userLevelId = getCookiesDecrypt("ulie");
|
||
const roleId = getCookiesDecrypt("urie");
|
||
const [isUserMabesApprover, setIsUserMabesApprover] = useState(false);
|
||
|
||
useEffect(() => {
|
||
initializeComponent();
|
||
}, []);
|
||
|
||
// useEffect(() => {
|
||
// const savedFlag = localStorage.getItem(`spit_saved_${id}`);
|
||
// if (savedFlag === "true") {
|
||
// setIsAlreadySaved(true);
|
||
// }
|
||
// }, [id]);
|
||
|
||
useEffect(() => {
|
||
checkUserPermissions();
|
||
}, [userLevelId, roleId]);
|
||
|
||
const initializeComponent = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
await Promise.all([
|
||
loadCategories(),
|
||
id ? loadDetail() : Promise.resolve(),
|
||
]);
|
||
} catch (error) {
|
||
console.error("Failed to initialize component:", error);
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Failed to load data. Please try again.",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const checkUserPermissions = () => {
|
||
if (userLevelId === "216" && roleId === "3") {
|
||
setIsUserMabesApprover(true);
|
||
}
|
||
};
|
||
|
||
const loadCategories = async () => {
|
||
try {
|
||
const response = await listEnableCategory("1");
|
||
const categories = response?.data?.data?.content || [];
|
||
setCategories(categories);
|
||
|
||
// Auto-select "Pers Rilis" category if schedule type is 3
|
||
const scheduleId = Cookies.get("scheduleId");
|
||
const scheduleType = Cookies.get("scheduleType");
|
||
|
||
if (scheduleId && scheduleType === "3") {
|
||
const persRilisCategory = categories.find((cat: Category) =>
|
||
cat.name.toLowerCase().includes("pers rilis")
|
||
);
|
||
if (persRilisCategory) {
|
||
setSelectedCategoryId(persRilisCategory.id);
|
||
await loadTags(persRilisCategory.id);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to load categories:", error);
|
||
}
|
||
};
|
||
|
||
const loadTags = async (categoryId: number) => {
|
||
try {
|
||
const response = await getTagsBySubCategoryId(categoryId);
|
||
if (response?.data?.data?.length > 0) {
|
||
const tagsMerge = [...tags, response?.data?.data];
|
||
setTags(tagsMerge);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to load tags:", error);
|
||
}
|
||
};
|
||
|
||
const loadDetail = async () => {
|
||
loading();
|
||
try {
|
||
const response = await detailSPIT(id);
|
||
const details = response?.data?.data;
|
||
setIsAlreadySaved(details?.isPublish ? true : false);
|
||
|
||
if (!details) {
|
||
throw new Error("Detail not found");
|
||
}
|
||
|
||
setDetail(details);
|
||
setFiles(details.contentList || []);
|
||
setDetailThumb(
|
||
(details.contentList || []).map(
|
||
(file: FileType) => file.contentFile || "default-image.jpg"
|
||
)
|
||
);
|
||
|
||
// Initialize file placements
|
||
const fileCount = details.contentList?.length || 0;
|
||
setFilePlacements(Array(fileCount).fill([]));
|
||
|
||
// Set form values
|
||
setValue("contentTitle", details.contentTitle || "");
|
||
setValue("contentDescription", details.contentDescription || "");
|
||
setValue("contentCreator", details.contentCreator || "");
|
||
setValue(
|
||
"contentRewriteDescription",
|
||
details.contentRewriteDescription || ""
|
||
);
|
||
|
||
if (details.categoryId) {
|
||
setSelectedCategoryId(details.categoryId);
|
||
await loadTags(details.categoryId);
|
||
}
|
||
|
||
if (details.contentTag) {
|
||
const initialTags = details.contentTag
|
||
.split(",")
|
||
.map((tag: string) => tag.trim());
|
||
setTags(initialTags);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to load detail:", error);
|
||
throw error;
|
||
}
|
||
close();
|
||
};
|
||
|
||
const handleCategoryChange = async (categoryId: string) => {
|
||
const id = Number(categoryId);
|
||
setSelectedCategoryId(id);
|
||
await loadTags(id);
|
||
};
|
||
|
||
const handleRewriteClick = async () => {
|
||
if (!detail?.contentDescription) {
|
||
MySwal.fire({
|
||
title: "Warning",
|
||
text: "Please add content description first",
|
||
icon: "warning",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsGeneratingRewrite(true);
|
||
const request = {
|
||
style: selectedWritingStyle,
|
||
lang: "id",
|
||
contextType: "text",
|
||
urlContext: null,
|
||
context: detail.contentDescription,
|
||
createdBy: roleId,
|
||
sentiment: "Humorous",
|
||
clientId: "7QTW8cMojyayt6qnhqTOeJaBI70W4EaQ",
|
||
};
|
||
|
||
const response = await generateDataRewrite(request);
|
||
|
||
if (response?.error) {
|
||
throw new Error(response.message);
|
||
}
|
||
|
||
const newArticleId = response?.data?.data?.id;
|
||
setArticleIds((prev) => {
|
||
const updated = [...prev];
|
||
if (updated.length < 3) {
|
||
updated.push(newArticleId);
|
||
} else {
|
||
updated[2] = newArticleId;
|
||
}
|
||
return updated;
|
||
});
|
||
|
||
setShowRewriteEditor(true);
|
||
MySwal.fire({
|
||
title: "Success",
|
||
text: "Content rewrite generated successfully",
|
||
icon: "success",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to generate rewrite:", error);
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Failed to generate content rewrite",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} finally {
|
||
setIsGeneratingRewrite(false);
|
||
}
|
||
};
|
||
|
||
const handleArticleSelect = async (articleId: string) => {
|
||
try {
|
||
setIsLoadingRewrite(true);
|
||
setSelectedArticleId(articleId);
|
||
|
||
let retryCount = 0;
|
||
const maxRetries = 20;
|
||
|
||
while (retryCount < maxRetries) {
|
||
const response = await getDetailArticle(articleId);
|
||
const articleData = response?.data?.data;
|
||
|
||
if (articleData?.status === 2) {
|
||
const cleanArticleBody = articleData.articleBody?.replace(
|
||
/<img[^>]*>/g,
|
||
""
|
||
);
|
||
setArticleBody(cleanArticleBody || "");
|
||
setValue("contentRewriteDescription", cleanArticleBody || "");
|
||
break;
|
||
}
|
||
|
||
retryCount++;
|
||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||
}
|
||
|
||
if (retryCount >= maxRetries) {
|
||
throw new Error("Timeout: Article processing took too long");
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to load article:", error);
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Failed to load article content",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} finally {
|
||
setIsLoadingRewrite(false);
|
||
}
|
||
};
|
||
|
||
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Enter" && inputRef.current?.value.trim()) {
|
||
e.preventDefault();
|
||
const newTag = inputRef.current.value.trim();
|
||
|
||
if (!tags.includes(newTag)) {
|
||
setTags((prev) => [...prev, newTag]);
|
||
}
|
||
|
||
inputRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const handleRemoveTag = (index: number) => {
|
||
setTags((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const handlePublishTargetChange = (optionId: string) => {
|
||
if (optionId === "all") {
|
||
setPublishedFor((prev) =>
|
||
prev.length === PUBLISH_OPTIONS.filter((opt) => opt.id !== "all").length
|
||
? []
|
||
: PUBLISH_OPTIONS.filter((opt) => opt.id !== "all").map(
|
||
(opt) => opt.id
|
||
)
|
||
);
|
||
} else {
|
||
setPublishedFor((prev) =>
|
||
prev.includes(optionId)
|
||
? prev.filter((id) => id !== optionId && id !== "all")
|
||
: [...prev.filter((id) => id !== "all"), optionId]
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleFilePlacementChange = (
|
||
fileIndex: number,
|
||
placement: string,
|
||
checked: boolean
|
||
) => {
|
||
setFilePlacements((prev) => {
|
||
const updated = [...prev];
|
||
const currentPlacements = updated[fileIndex] || [];
|
||
|
||
if (checked) {
|
||
if (placement === "all") {
|
||
updated[fileIndex] = ["all", "mabes", "polda", "international"];
|
||
} else {
|
||
const newPlacements = [...currentPlacements, placement];
|
||
if (newPlacements.length === 3 && !newPlacements.includes("all")) {
|
||
newPlacements.push("all");
|
||
}
|
||
updated[fileIndex] = newPlacements;
|
||
}
|
||
} else {
|
||
if (placement === "all") {
|
||
updated[fileIndex] = [];
|
||
} else {
|
||
const newPlacements = currentPlacements.filter(
|
||
(p) => p !== placement
|
||
);
|
||
if (newPlacements.length === 3 && newPlacements.includes("all")) {
|
||
updated[fileIndex] = newPlacements.filter((p) => p !== "all");
|
||
} else {
|
||
updated[fileIndex] = newPlacements;
|
||
}
|
||
}
|
||
}
|
||
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const handleSelectAllPlacements = (placement: string, checked: boolean) => {
|
||
setFilePlacements((prev) =>
|
||
prev.map((filePlacements) =>
|
||
checked
|
||
? Array.from(new Set([...filePlacements, placement]))
|
||
: filePlacements.filter((p) => p !== placement)
|
||
)
|
||
);
|
||
};
|
||
|
||
const getPlacementData = () => {
|
||
const placementData = [];
|
||
console.log("filePlacements : ", filePlacements);
|
||
for (let i = 0; i < filePlacements.length; i++) {
|
||
if (filePlacements[i].length > 0) {
|
||
const placements = filePlacements[i];
|
||
placementData.push({
|
||
mediaFileId: files[i].contentId,
|
||
placements: placements.join(","),
|
||
});
|
||
}
|
||
}
|
||
return placementData;
|
||
};
|
||
|
||
const checkPlacement = (data: any) => {
|
||
let temp = true;
|
||
for (const element of data) {
|
||
if (element.length < 1) {
|
||
temp = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return temp;
|
||
};
|
||
|
||
// Form submission
|
||
const onSubmit = async (data: FormData) => {
|
||
const pnmhTags = tags.filter((tag) => tag.toLowerCase().includes("pnmh"));
|
||
|
||
if (pnmhTags.length > 1) {
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Tags penugasan hanya diperbolehkan 1 (satu) saja.",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
return false;
|
||
}
|
||
|
||
if (!checkPlacement(filePlacements)) {
|
||
error("Select File Placement");
|
||
return false;
|
||
}
|
||
|
||
if (!selectedCategoryId) {
|
||
error("Select a category");
|
||
return false;
|
||
}
|
||
|
||
if (publishedFor.length < 1) {
|
||
error("Select Publish Target");
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
setIsSaving(true);
|
||
|
||
const description =
|
||
selectedFileType === "original"
|
||
? data.contentDescription
|
||
: data.contentRewriteDescription;
|
||
|
||
const requestData = {
|
||
spitId: id,
|
||
title: data.contentTitle,
|
||
description,
|
||
htmlDescription: description,
|
||
tags: tags.join(", "),
|
||
categoryId: selectedCategoryId,
|
||
publishedFor: publishedFor.join(","),
|
||
creator: data.contentCreator,
|
||
files: isUserMabesApprover ? getPlacementData() : [],
|
||
};
|
||
|
||
await convertSPIT(requestData);
|
||
|
||
// localStorage.setItem(`spit_saved_${id}`, "true");
|
||
// setIsAlreadySaved(true);
|
||
|
||
MySwal.fire({
|
||
title: "Success",
|
||
text: "Data saved successfully",
|
||
icon: "success",
|
||
confirmButtonColor: "#3085d6",
|
||
}).then(() => {
|
||
// router.push("/in/contributor/content/spit");
|
||
// router.replace(`${window.location.pathname}?id=${id}`);
|
||
loadDetail();
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to save:", error);
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Failed to save data",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
const result = await MySwal.fire({
|
||
title: "Delete Content",
|
||
text: "Are you sure you want to delete this content?",
|
||
icon: "warning",
|
||
showCancelButton: true,
|
||
confirmButtonColor: "#dc3545",
|
||
cancelButtonColor: "#6c757d",
|
||
confirmButtonText: "Delete",
|
||
cancelButtonText: "Cancel",
|
||
});
|
||
|
||
if (result.isConfirmed) {
|
||
try {
|
||
setIsDeleting(true);
|
||
await deleteSPIT(id);
|
||
|
||
MySwal.fire({
|
||
title: "Success",
|
||
text: "Content deleted successfully",
|
||
icon: "success",
|
||
confirmButtonColor: "#3085d6",
|
||
}).then(() => {
|
||
router.back();
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to delete:", error);
|
||
MySwal.fire({
|
||
title: "Error",
|
||
text: "Failed to delete content",
|
||
icon: "error",
|
||
confirmButtonColor: "#3085d6",
|
||
});
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Loading state
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[400px]">
|
||
<div className="text-center">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||
<p className="text-muted-foreground">Loading form data...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto py-6 space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight">
|
||
SPIT Convert Form
|
||
</h1>
|
||
<p className="text-muted-foreground">
|
||
Convert and manage your SPIT content
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||
<XCircle className="h-4 w-4 mr-2" />
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
color="destructive"
|
||
size="sm"
|
||
onClick={handleDelete}
|
||
disabled={isDeleting}
|
||
>
|
||
{isDeleting ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
)}
|
||
Delete
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Main Content */}
|
||
<div className="lg:col-span-2 space-y-6">
|
||
{/* Basic Information */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<FileText className="h-5 w-5" />
|
||
Basic Information
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* Title */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title">Title *</Label>
|
||
<Controller
|
||
control={control}
|
||
name="contentTitle"
|
||
render={({ field }) => (
|
||
<Input
|
||
id="title"
|
||
placeholder="Enter content title"
|
||
{...field}
|
||
/>
|
||
)}
|
||
/>
|
||
{errors.contentTitle && (
|
||
<Alert variant="soft">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
{errors.contentTitle.message}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</div>
|
||
|
||
{/* Category */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="category">Category</Label>
|
||
<Select
|
||
value={selectedCategoryId?.toString()}
|
||
onValueChange={handleCategoryChange}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select category" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map((category) => (
|
||
<SelectItem
|
||
key={category.id}
|
||
value={category.id.toString()}
|
||
>
|
||
{category.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Content Editor */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Edit3 className="h-5 w-5" />
|
||
Content Editor
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<RadioGroup
|
||
value={selectedFileType}
|
||
onValueChange={(value: "original" | "rewrite") =>
|
||
setSelectedFileType(value)
|
||
}
|
||
className="grid grid-cols-2 gap-4"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="original" id="original" />
|
||
<Label htmlFor="original">Original Content</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="rewrite" id="rewrite" />
|
||
<Label htmlFor="rewrite">Rewritten Content</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
|
||
{/* Original Content */}
|
||
{selectedFileType === "original" && (
|
||
<div className="space-y-2">
|
||
<Label>Content Description</Label>
|
||
<Controller
|
||
control={control}
|
||
name="contentDescription"
|
||
render={({ field }) => (
|
||
<CustomEditor
|
||
onChange={field.onChange}
|
||
initialData={field.value}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content Rewrite */}
|
||
{selectedFileType === "rewrite" && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-2">
|
||
<Label>Writing Style</Label>
|
||
<Select
|
||
value={selectedWritingStyle}
|
||
onValueChange={setSelectedWritingStyle}
|
||
>
|
||
<SelectTrigger className="w-48">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{WRITING_STYLES.map((style) => (
|
||
<SelectItem key={style.value} value={style.value}>
|
||
{style.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
onClick={handleRewriteClick}
|
||
disabled={
|
||
isGeneratingRewrite || !detail?.contentDescription
|
||
}
|
||
className="bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
{isGeneratingRewrite ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Edit3 className="h-4 w-4 mr-2" />
|
||
)}
|
||
Generate Rewrite
|
||
</Button>
|
||
</div>
|
||
|
||
{showRewriteEditor && (
|
||
<div className="space-y-4">
|
||
{articleIds.length > 0 && (
|
||
<div className="flex gap-2">
|
||
{articleIds.map((articleId, index) => (
|
||
<Button
|
||
key={articleId}
|
||
type="button"
|
||
variant={
|
||
selectedArticleId === articleId
|
||
? "default"
|
||
: "outline"
|
||
}
|
||
size="sm"
|
||
onClick={() => handleArticleSelect(articleId)}
|
||
disabled={isLoadingRewrite}
|
||
>
|
||
{isLoadingRewrite &&
|
||
selectedArticleId === articleId && (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
)}
|
||
Narrative {index + 1}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<Label>Rewritten Content</Label>
|
||
<Controller
|
||
control={control}
|
||
name="contentRewriteDescription"
|
||
render={({ field }) => (
|
||
<CustomEditor
|
||
onChange={field.onChange}
|
||
initialData={articleBody || field.value}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Media Files */}
|
||
{detailThumb.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Image className="h-5 w-5" />
|
||
Media Files
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-4">
|
||
<Swiper
|
||
thumbs={{ swiper: thumbsSwiper }}
|
||
modules={[FreeMode, Navigation, Thumbs]}
|
||
navigation={true}
|
||
className="w-full h-96"
|
||
>
|
||
{detailThumb.map((image, index) => (
|
||
<SwiperSlide key={index}>
|
||
<img
|
||
src={image}
|
||
alt={`Media ${index + 1}`}
|
||
className="w-full h-full object-cover rounded-lg"
|
||
/>
|
||
</SwiperSlide>
|
||
))}
|
||
</Swiper>
|
||
|
||
<Swiper
|
||
onSwiper={setThumbsSwiper}
|
||
slidesPerView={8}
|
||
spaceBetween={8}
|
||
modules={[Pagination, Thumbs]}
|
||
className="w-full"
|
||
>
|
||
{detailThumb.map((image, index) => (
|
||
<SwiperSlide key={index}>
|
||
<img
|
||
src={image}
|
||
alt={`Thumbnail ${index + 1}`}
|
||
className="w-full h-16 object-cover rounded cursor-pointer"
|
||
/>
|
||
</SwiperSlide>
|
||
))}
|
||
</Swiper>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* File Placement */}
|
||
{files.length > 0 && isUserMabesApprover && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Globe className="h-5 w-5" />
|
||
File Placement
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{files.length > 1 && (
|
||
<div className="flex flex-wrap gap-4 p-4 bg-muted/50 rounded-lg">
|
||
{PLACEMENT_OPTIONS.map((option) => (
|
||
<div
|
||
key={option.value}
|
||
className="flex items-center space-x-2"
|
||
>
|
||
<Checkbox
|
||
id={`select-all-${option.value}`}
|
||
onCheckedChange={(checked) =>
|
||
handleSelectAllPlacements(
|
||
option.value,
|
||
Boolean(checked)
|
||
)
|
||
}
|
||
/>
|
||
<Label
|
||
htmlFor={`select-all-${option.value}`}
|
||
className="text-sm"
|
||
>
|
||
All {option.label}
|
||
</Label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{files.map((file, index) => (
|
||
<div
|
||
key={file.contentId}
|
||
className="flex gap-4 p-4 border rounded-lg"
|
||
>
|
||
<img
|
||
src={file.contentFile}
|
||
alt={file.fileName}
|
||
className="w-32 h-24 object-cover rounded"
|
||
/>
|
||
<div className="flex-1 space-y-3">
|
||
<p className="font-medium text-sm">{file.fileName}</p>
|
||
<div className="flex flex-wrap gap-3">
|
||
{PLACEMENT_OPTIONS.map((option) => (
|
||
<div
|
||
key={option.value}
|
||
className="flex items-center space-x-2"
|
||
>
|
||
<Checkbox
|
||
id={`${file.contentId}-${option.value}`}
|
||
checked={filePlacements[index]?.includes(
|
||
option.value
|
||
)}
|
||
onCheckedChange={(checked) =>
|
||
handleFilePlacementChange(
|
||
index,
|
||
option.value,
|
||
Boolean(checked)
|
||
)
|
||
}
|
||
/>
|
||
<Label
|
||
htmlFor={`${file.contentId}-${option.value}`}
|
||
className="text-sm"
|
||
>
|
||
{option.label}
|
||
</Label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className="space-y-6">
|
||
{/* Creator Information */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Users className="h-5 w-5" />
|
||
Creator Information
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="creator">Creator *</Label>
|
||
<Controller
|
||
control={control}
|
||
name="contentCreator"
|
||
render={({ field }) => (
|
||
<Input
|
||
id="creator"
|
||
placeholder="Enter creator name"
|
||
{...field}
|
||
/>
|
||
)}
|
||
/>
|
||
{errors.contentCreator && (
|
||
<Alert variant="soft">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
{errors.contentCreator.message}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Preview */}
|
||
{detail?.contentThumbnail && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Eye className="h-5 w-5" />
|
||
Preview
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<img
|
||
src={detail.contentThumbnail}
|
||
alt="Content thumbnail"
|
||
className="w-full h-auto rounded-lg"
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Tags */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Tag className="h-5 w-5" />
|
||
Tags
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tag-input">Add Tags</Label>
|
||
<Input
|
||
id="tag-input"
|
||
placeholder="Type a tag and press Enter"
|
||
onKeyDown={handleAddTag}
|
||
ref={inputRef}
|
||
/>
|
||
</div>
|
||
{tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{tags.map((tag, index) => (
|
||
<Badge
|
||
key={index}
|
||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
|
||
onClick={() => handleRemoveTag(index)}
|
||
>
|
||
{tag}
|
||
<XCircle className="h-3 w-3 ml-1" />
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Publish Targets */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Settings className="h-5 w-5" />
|
||
Publish Targets
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{PUBLISH_OPTIONS.map((option) => (
|
||
<div key={option.id} className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id={option.id}
|
||
checked={
|
||
option.id === "all"
|
||
? publishedFor.length ===
|
||
PUBLISH_OPTIONS.filter((opt) => opt.id !== "all")
|
||
.length
|
||
: publishedFor.includes(option.id)
|
||
}
|
||
onCheckedChange={() =>
|
||
handlePublishTargetChange(option.id)
|
||
}
|
||
/>
|
||
<Label htmlFor={option.id} className="text-sm">
|
||
{option.label}
|
||
</Label>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Submit Button */}
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<Button
|
||
type="submit"
|
||
className="w-full mb-4"
|
||
disabled={isSubmitting || isSaving || isAlreadySaved}
|
||
>
|
||
{isSubmitting || isSaving ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Save className="h-4 w-4 mr-2" />
|
||
)}
|
||
{isAlreadySaved
|
||
? "Already Saved"
|
||
: isSubmitting || isSaving
|
||
? "Saving..."
|
||
: "Save Changes"}
|
||
</Button>
|
||
|
||
{isAlreadySaved && (
|
||
<Alert variant="soft">
|
||
<CheckCircle className="h-4 w-4 text-red-500" />
|
||
<AlertDescription className="text-red-500">
|
||
Konten sudah disimpan. Anda tidak dapat menyimpan ulang.
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|