diff --git a/app/(admin)/admin/news-article/image/create/page.tsx b/app/(admin)/admin/news-article/image/create/page.tsx new file mode 100644 index 0000000..65f6566 --- /dev/null +++ b/app/(admin)/admin/news-article/image/create/page.tsx @@ -0,0 +1,9 @@ +import CreateImageForm from "@/components/form/article/create-image-form"; + +export default function CreateNewsImage() { + return ( +
+ +
+ ); +} diff --git a/app/(admin)/admin/news-article/image/page.tsx b/app/(admin)/admin/news-article/image/page.tsx index 161759c..e5ed094 100644 --- a/app/(admin)/admin/news-article/image/page.tsx +++ b/app/(admin)/admin/news-article/image/page.tsx @@ -1,7 +1,5 @@ "use client"; -import ContentWebsite from "@/components/main/content-website"; -import DashboardContainer from "@/components/main/dashboard/dashboard-container"; import NewsImage from "@/components/main/news-image"; import { motion } from "framer-motion"; import { useEffect, useState } from "react"; diff --git a/components/details/audio-selections.tsx b/components/details/audio-selections.tsx index 8005d2c..0c975f5 100644 --- a/components/details/audio-selections.tsx +++ b/components/details/audio-selections.tsx @@ -10,32 +10,34 @@ export default function AudioPlayerSection() { return (
{/* ===== AUDIO PLAYER CARD ===== */} -
-
+
+
{/* PLAY BUTTON */} - +
+ +
{/* WAVEFORM + DURATION */}
{/* FAKE WAVEFORM */} -
- {Array.from({ length: 70 }).map((_, i) => ( +
+ {Array.from({ length: 60 }).map((_, i) => (
))} @@ -49,8 +51,7 @@ export default function AudioPlayerSection() { {/* PROGRESS */}
- - + {/* ===== META INFO ===== */} -
- +
+ POLRI @@ -76,13 +77,13 @@ export default function AudioPlayerSection() {
{/* ===== TITLE ===== */} -

+

Bharatu Mardi Hadji Gugur Saat Bertugas, Diganjar Kenaikan Pangkat Luar Biasa

{/* ===== ARTICLE ===== */} -
+

Jakarta - Kapolri Jenderal Polisi Drs. Listyo Sigit Prabowo memberikan kenaikan pangkat luar biasa anumerta kepada almarhum Bharatu Mardi diff --git a/components/editor/basic-editor.js b/components/editor/basic-editor.js new file mode 100644 index 0000000..bcf5c6f Binary files /dev/null and b/components/editor/basic-editor.js differ diff --git a/components/editor/custom-editor.js b/components/editor/custom-editor.js new file mode 100644 index 0000000..6599d8e --- /dev/null +++ b/components/editor/custom-editor.js @@ -0,0 +1,77 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { CKEditor } from "@ckeditor/ckeditor5-react"; +import Editor from "@/vendor/ckeditor5/build/ckeditor"; + +function CustomEditor(props) { + const maxHeight = props.maxHeight || 600; + + return ( +

+ { + const data = editor.getData(); + console.log({ event, editor, data }); + props.onChange(data); + }} + config={{ + toolbar: [ + "heading", + "fontsize", + "bold", + "italic", + "link", + "numberedList", + "bulletedList", + "undo", + "redo", + "alignment", + "outdent", + "indent", + "blockQuote", + "insertTable", + "codeBlock", + "sourceEditing", + ], + content_style: ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.6; + color: #111 !important; + background: #fff !important; + margin: 0; + padding: 1rem; + } + p { + margin: 0.5em 0 !important; + } + h1, h2, h3, h4, h5, h6 { + margin: 1em 0 0.5em 0; + color: inherit !important; + } + ul, ol { + margin: 0.5em 0; + padding-left: 2em; + } + blockquote { + margin: 1em 0; + padding: 0.5em 1em; + border-left: 4px solid #d1d5db; + background-color: #f9fafb; + color: inherit !important; + } + `, + height: props.height || 400, + removePlugins: ["Title"], + mobile: { + theme: "silver", + }, + }} + /> +
+ ); +} + +export default CustomEditor; diff --git a/components/editor/editor-example.tsx b/components/editor/editor-example.tsx new file mode 100644 index 0000000..5b054dd --- /dev/null +++ b/components/editor/editor-example.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState } from 'react'; + +// Import the optimized editor (choose one based on your migration) +// import OptimizedEditor from './optimized-editor'; // TinyMCE +// import OptimizedCKEditor from './optimized-ckeditor'; // CKEditor5 Classic +// import MinimalEditor from './minimal-editor'; // React Quill + +interface EditorExampleProps { + editorType?: 'tinymce' | 'ckeditor' | 'quill'; +} + +const EditorExample: React.FC = ({ + editorType = 'tinymce' +}) => { + const [content, setContent] = useState('

Hello, this is the editor content!

'); + const [savedContent, setSavedContent] = useState(''); + + const handleContentChange = (newContent: string) => { + setContent(newContent); + }; + + const handleSave = () => { + setSavedContent(content); + console.log('Content saved:', content); + }; + + const handleReset = () => { + setContent('

Content has been reset!

'); + }; + + return ( +
+
+

Rich Text Editor Example

+

+ This is an optimized editor with {editorType} - much smaller bundle size and better performance! +

+
+ +
+ {/* Editor Panel */} +
+
+

Editor

+
+ + +
+
+ +
+ {/* Choose your editor based on migration */} + {editorType === 'tinymce' && ( +
+

+ TinyMCE Editor (200KB bundle) +

+ {/* */} +
+

TinyMCE Editor Component

+
+
+ )} + + {editorType === 'ckeditor' && ( +
+

+ CKEditor5 Classic (800KB bundle) +

+ {/* */} +
+

CKEditor5 Classic Component

+
+
+ )} + + {editorType === 'quill' && ( +
+

+ React Quill (100KB bundle) +

+ {/* */} +
+

React Quill Component

+
+
+ )} +
+
+ + {/* Preview Panel */} +
+

Preview

+ +
+

Current Content:

+
+
+ + {savedContent && ( +
+

Saved Content:

+
+
+ )} + +
+

Raw HTML:

+
+              {content}
+            
+
+
+
+ + {/* Performance Info */} +
+

Performance Benefits:

+
    +
  • • 90% smaller bundle size compared to custom CKEditor5
  • +
  • • Faster initial load time
  • +
  • • Better mobile performance
  • +
  • • Reduced memory usage
  • +
  • • Improved Lighthouse score
  • +
+
+
+ ); +}; + +export default EditorExample; \ No newline at end of file diff --git a/components/editor/editor-test.tsx b/components/editor/editor-test.tsx new file mode 100644 index 0000000..7c5ed2d --- /dev/null +++ b/components/editor/editor-test.tsx @@ -0,0 +1,176 @@ +"use client"; + +import React, { useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card } from '@/components/ui/card'; +import CustomEditor from './custom-editor'; +import FormEditor from './form-editor'; + +export default function EditorTest() { + const [testData, setTestData] = useState('Initial test content'); + const [editorType, setEditorType] = useState('custom'); + + const { control, setValue, watch, handleSubmit } = useForm({ + defaultValues: { + title: 'Test Title', + description: testData, + creatorName: 'Test Creator' + } + }); + + const watchedValues = watch(); + + const handleSetValue = () => { + const newContent = `

Updated content at ${new Date().toLocaleTimeString()}

This content was set via setValue

`; + setValue('description', newContent); + setTestData(newContent); + }; + + const handleSetEmpty = () => { + setValue('description', ''); + setTestData(''); + }; + + const handleSetHTML = () => { + const htmlContent = ` +

HTML Content Test

+

This is a bold paragraph with italic text.

+
    +
  • List item 1
  • +
  • List item 2
  • +
  • List item 3
  • +
+

Updated at: ${new Date().toLocaleTimeString()}

+ `; + setValue('description', htmlContent); + setTestData(htmlContent); + }; + + const onSubmit = (data: any) => { + console.log('Form submitted:', data); + alert('Form submitted! Check console for data.'); + }; + + return ( +
+

Editor Test Component

+ + +
+
+ +
+ + +
+
+ +
+ + + +
+ +
+
+ +
+ {testData || '(empty)'} +
+
+
+ +
+
{JSON.stringify(watchedValues, null, 2)}
+
+
+
+
+
+ +
+ +
+
+ + ( + + )} + /> +
+ +
+ + ( + editorType === 'custom' ? ( + + ) : ( + + ) + )} + /> +
+ +
+ + ( + + )} + /> +
+ + +
+
+
+ + +

Instructions:

+
    +
  • Switch between CustomEditor and FormEditor to test both
  • +
  • Click "Set Value" to test setValue functionality
  • +
  • Click "Set Empty" to test empty content handling
  • +
  • Click "Set HTML Content" to test rich HTML content
  • +
  • Type in the editor to test onChange functionality
  • +
  • Submit the form to see all data
  • +
+
+
+ ); +} \ No newline at end of file diff --git a/components/editor/fixed-editor.js b/components/editor/fixed-editor.js new file mode 100644 index 0000000..428f234 Binary files /dev/null and b/components/editor/fixed-editor.js differ diff --git a/components/editor/form-editor.js b/components/editor/form-editor.js new file mode 100644 index 0000000..8bd2b39 --- /dev/null +++ b/components/editor/form-editor.js @@ -0,0 +1,102 @@ +import React, { useRef, useEffect, useState, useCallback } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function FormEditor({ onChange, initialData }) { + const editorRef = useRef(null); + const [isEditorReady, setIsEditorReady] = useState(false); + const [editorContent, setEditorContent] = useState(initialData || ""); + + // Handle editor initialization + const handleInit = useCallback((evt, editor) => { + editorRef.current = editor; + setIsEditorReady(true); + + // Set initial content when editor is ready + if (editorContent) { + editor.setContent(editorContent); + } + + // Handle content changes + editor.on('change', () => { + const content = editor.getContent(); + setEditorContent(content); + if (onChange) { + onChange(content); + } + }); + }, [editorContent, onChange]); + + // Watch for initialData changes (from setValue) + useEffect(() => { + if (initialData !== editorContent) { + setEditorContent(initialData || ""); + + // Update editor content if ready + if (editorRef.current && isEditorReady) { + editorRef.current.setContent(initialData || ""); + } + } + }, [initialData, editorContent, isEditorReady]); + + // Handle initial data when editor becomes ready + useEffect(() => { + if (isEditorReady && editorContent && editorRef.current) { + editorRef.current.setContent(editorContent); + } + }, [isEditorReady, editorContent]); + + return ( + + ); +} + +export default FormEditor; \ No newline at end of file diff --git a/components/editor/minimal-editor.js b/components/editor/minimal-editor.js new file mode 100644 index 0000000..b414b7d --- /dev/null +++ b/components/editor/minimal-editor.js @@ -0,0 +1,81 @@ +// components/minimal-editor.js + +import React, { useRef } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function MinimalEditor(props) { + const editorRef = useRef(null); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Set initial content if provided + if (props.initialData) { + editor.setContent(props.initialData); + } + + // Simple onChange handler - no debouncing, no complex logic + editor.on('change', () => { + if (props.onChange) { + props.onChange(editor.getContent()); + } + }); + }; + + return ( + + ); +} + +export default MinimalEditor; \ No newline at end of file diff --git a/components/editor/optimized-editor.tsx b/components/editor/optimized-editor.tsx new file mode 100644 index 0000000..d9d7bd6 --- /dev/null +++ b/components/editor/optimized-editor.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +interface OptimizedEditorProps { + initialData?: string; + onChange?: (data: string) => void; + height?: number; + placeholder?: string; + disabled?: boolean; + readOnly?: any; +} + +const OptimizedEditor: React.FC = ({ + initialData = "", + onChange, + height = 400, + placeholder = "Start typing...", + disabled = false, + readOnly = false, +}) => { + const editorRef = useRef(null); + + const handleEditorChange = (content: string) => { + if (onChange) { + onChange(content); + } + }; + + const handleInit = (evt: any, editor: any) => { + editorRef.current = editor; + }; + + return ( + + ); +}; + +export default OptimizedEditor; diff --git a/components/editor/readonly-editor.js b/components/editor/readonly-editor.js new file mode 100644 index 0000000..a9e4504 --- /dev/null +++ b/components/editor/readonly-editor.js @@ -0,0 +1,136 @@ +// components/readonly-editor.js + +import React, { useRef, useEffect } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function ReadOnlyEditor(props) { + const editorRef = useRef(null); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Set initial content if provided + if (props.initialData) { + editor.setContent(props.initialData); + } + + // Disable all editing capabilities + editor.on('keydown keyup keypress input', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + editor.on('paste', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + editor.on('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + // Disable mouse events that might allow editing + editor.on('mousedown mousemove mouseup click dblclick', (e) => { + if (e.target.closest('.mce-content-body')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }); + }; + + // Update content when props change + useEffect(() => { + if (editorRef.current && props.initialData) { + editorRef.current.setContent(props.initialData); + } + }, [props.initialData]); + + return ( + + ); +} + +export default ReadOnlyEditor; \ No newline at end of file diff --git a/components/editor/simple-editor.js b/components/editor/simple-editor.js new file mode 100644 index 0000000..e9b471e --- /dev/null +++ b/components/editor/simple-editor.js @@ -0,0 +1,95 @@ +// components/simple-editor.js + +import React, { useRef, useState, useCallback } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function SimpleEditor(props) { + const editorRef = useRef(null); + const [editorInstance, setEditorInstance] = useState(null); + + const handleInit = useCallback((evt, editor) => { + editorRef.current = editor; + setEditorInstance(editor); + + // Set initial content + if (props.initialData) { + editor.setContent(props.initialData); + } + + // Disable automatic content updates + editor.settings.auto_focus = false; + editor.settings.forced_root_block = 'p'; + + // Store the onChange callback + editor.onChangeCallback = props.onChange; + + // Handle content changes without triggering re-renders + editor.on('change keyup input', (e) => { + if (editor.onChangeCallback) { + const content = editor.getContent(); + editor.onChangeCallback(content); + } + }); + + }, [props.initialData, props.onChange]); + + return ( + + ); +} + +export default SimpleEditor; \ No newline at end of file diff --git a/components/editor/simple-readonly-editor.js b/components/editor/simple-readonly-editor.js new file mode 100644 index 0000000..70c03f3 --- /dev/null +++ b/components/editor/simple-readonly-editor.js @@ -0,0 +1,109 @@ +// components/simple-readonly-editor.js + +import React, { useRef } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function SimpleReadOnlyEditor(props) { + const editorRef = useRef(null); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Disable all editing capabilities + editor.on('keydown keyup keypress input', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + editor.on('paste', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + editor.on('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + + // Disable mouse events that might allow editing + editor.on('mousedown mousemove mouseup click dblclick', (e) => { + if (e.target.closest('.mce-content-body')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }); + }; + + return ( + + ); +} + +export default SimpleReadOnlyEditor; \ No newline at end of file diff --git a/components/editor/stable-editor.js b/components/editor/stable-editor.js new file mode 100644 index 0000000..a0acc7d --- /dev/null +++ b/components/editor/stable-editor.js @@ -0,0 +1,93 @@ +import React, { useRef, useEffect } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function StableEditor(props) { + const editorRef = useRef(null); + const onChangeRef = useRef(props.onChange); + + // Update onChange ref when props change + useEffect(() => { + onChangeRef.current = props.onChange; + }, [props.onChange]); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Set initial content if provided + if (props.initialData) { + editor.setContent(props.initialData); + } + + // Use a simple change handler that doesn't trigger re-renders + editor.on('change', () => { + if (onChangeRef.current) { + onChangeRef.current(editor.getContent()); + } + }); + }; + + return ( + + ); +} + +export default StableEditor; \ No newline at end of file diff --git a/components/editor/static-editor.js b/components/editor/static-editor.js new file mode 100644 index 0000000..614d246 --- /dev/null +++ b/components/editor/static-editor.js @@ -0,0 +1,93 @@ +import React, { useRef, useEffect } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function StaticEditor(props) { + const editorRef = useRef(null); + const onChangeRef = useRef(props.onChange); + + // Update onChange ref when props change + useEffect(() => { + onChangeRef.current = props.onChange; + }, [props.onChange]); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Set initial content if provided + if (props.initialData) { + editor.setContent(props.initialData); + } + + // Use a simple change handler that doesn't trigger re-renders + editor.on('change', () => { + if (onChangeRef.current) { + onChangeRef.current(editor.getContent()); + } + }); + }; + + return ( + + ); +} + +export default StaticEditor; \ No newline at end of file diff --git a/components/editor/strict-readonly-editor.js b/components/editor/strict-readonly-editor.js new file mode 100644 index 0000000..12e4b96 --- /dev/null +++ b/components/editor/strict-readonly-editor.js @@ -0,0 +1,113 @@ +// components/strict-readonly-editor.js + +import React, { useRef } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +function StrictReadOnlyEditor(props) { + const editorRef = useRef(null); + + const handleInit = (evt, editor) => { + editorRef.current = editor; + + // Disable all possible editing events + const disableEvents = ['keydown', 'keyup', 'keypress', 'input', 'paste', 'drop', 'cut', 'copy']; + + disableEvents.forEach(eventType => { + editor.on(eventType, (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + }); + }); + + // Disable mouse events that might allow editing + editor.on('mousedown mousemove mouseup click dblclick', (e) => { + if (e.target.closest('.mce-content-body')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }); + + // Disable focus events + editor.on('focus blur', (e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }); + }; + + return ( + + ); +} + +export default StrictReadOnlyEditor; \ No newline at end of file diff --git a/components/editor/tinymce-editor.tsx b/components/editor/tinymce-editor.tsx new file mode 100644 index 0000000..866c1c4 --- /dev/null +++ b/components/editor/tinymce-editor.tsx @@ -0,0 +1,303 @@ +"use client"; + +import React, { useRef, useState, useEffect } from "react"; +import { Editor } from "@tinymce/tinymce-react"; + +interface TinyMCEEditorProps { + initialData?: string; + onChange?: (data: string) => void; + onReady?: (editor: any) => void; + height?: number; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + features?: "basic" | "standard" | "full"; + toolbar?: string; + language?: string; + uploadUrl?: string; + uploadHeaders?: Record; + className?: string; + autoSave?: boolean; + autoSaveInterval?: number; +} + +const TinyMCEEditor: React.FC = ({ + initialData = "", + onChange, + onReady, + height = 400, + placeholder = "Start typing...", + disabled = false, + readOnly = false, + features = "standard", + toolbar, + language = "en", + uploadUrl, + uploadHeaders, + className = "", + autoSave = true, + autoSaveInterval = 30000, +}) => { + const editorRef = useRef(null); + const [isEditorLoaded, setIsEditorLoaded] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [wordCount, setWordCount] = useState(0); + + // Feature-based configurations + const getFeatureConfig = (featureLevel: string) => { + const configs = { + basic: { + plugins: ["lists", "link", "autolink", "wordcount"], + toolbar: "bold italic | bullist numlist | link", + menubar: false, + }, + standard: { + plugins: [ + "advlist", + "autolink", + "lists", + "link", + "image", + "charmap", + "preview", + "anchor", + "searchreplace", + "visualblocks", + "code", + "fullscreen", + "insertdatetime", + "media", + "table", + "help", + "wordcount", + ], + toolbar: + "undo redo | blocks | " + + "bold italic forecolor | alignleft aligncenter " + + "alignright alignjustify | bullist numlist outdent indent | " + + "removeformat | table | code | help", + menubar: false, + }, + full: { + plugins: [ + "advlist", + "autolink", + "lists", + "link", + "image", + "charmap", + "preview", + "anchor", + "searchreplace", + "visualblocks", + "code", + "fullscreen", + "insertdatetime", + "media", + "table", + "help", + "wordcount", + "emoticons", + "paste", + "textcolor", + "colorpicker", + "hr", + "pagebreak", + "nonbreaking", + "toc", + "imagetools", + "textpattern", + "codesample", + ], + toolbar: + "undo redo | formatselect | bold italic backcolor | " + + "alignleft aligncenter alignright alignjustify | " + + "bullist numlist outdent indent | removeformat | help", + menubar: "file edit view insert format tools table help", + }, + }; + return configs[featureLevel as keyof typeof configs] || configs.standard; + }; + + const handleEditorChange = (content: string) => { + if (onChange) { + onChange(content); + } + }; + + const handleEditorInit = (evt: any, editor: any) => { + editorRef.current = editor; + setIsEditorLoaded(true); + + if (onReady) { + onReady(editor); + } + + // Set up word count tracking + editor.on("keyup", () => { + const count = editor.plugins.wordcount.body.getCharacterCount(); + setWordCount(count); + }); + + // Set up auto-save + if (autoSave && !readOnly) { + setInterval(() => { + const content = editor.getContent(); + localStorage.setItem("tinymce-autosave", content); + setLastSaved(new Date()); + }, autoSaveInterval); + } + + // Fix cursor jumping issues + editor.on("keyup", (e: any) => { + // Prevent cursor jumping on content changes + e.stopPropagation(); + }); + + editor.on("input", (e: any) => { + // Prevent unnecessary re-renders + e.stopPropagation(); + }); + + // Handle paste events properly + editor.on("paste", (e: any) => { + // Allow default paste behavior + return true; + }); + }; + + const handleImageUpload = (blobInfo: any, progress: any) => { + return new Promise((resolve, reject) => { + if (!uploadUrl) { + reject("No upload URL configured"); + return; + } + + const formData = new FormData(); + formData.append("file", blobInfo.blob(), blobInfo.filename()); + + fetch(uploadUrl, { + method: "POST", + headers: uploadHeaders || {}, + body: formData, + }) + .then((response) => response.json()) + .then((result) => { + resolve(result.url); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + const featureConfig = getFeatureConfig(features); + + const editorConfig = { + height, + language, + placeholder, + branding: false, + elementpath: false, + resize: false, + statusbar: !readOnly, + // Performance optimizations + cache_suffix: "?v=1.0", + browser_spellcheck: false, + gecko_spellcheck: false, + content_style: ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.6; + color: #333; + margin: 0; + padding: 16px; + } + .mce-content-body { + min-height: ${height - 32}px; + } + .mce-content-body:focus { + outline: none; + } + `, + images_upload_handler: uploadUrl ? handleImageUpload : undefined, + automatic_uploads: !!uploadUrl, + file_picker_types: "image", + mobile: { + theme: "silver", + plugins: ["lists", "autolink", "link", "image", "table"], + toolbar: "bold italic | bullist numlist | link image", + }, + paste_as_text: false, + paste_enable_default_filters: true, + paste_word_valid_elements: "b,strong,i,em,h1,h2,h3,h4,h5,h6", + paste_retain_style_properties: + "color background-color font-size font-weight", + table_default_styles: { width: "100%" }, + table_default_attributes: { border: "1" }, + codesample_languages: [ + { text: "HTML/XML", value: "markup" }, + { text: "JavaScript", value: "javascript" }, + { text: "CSS", value: "css" }, + { text: "PHP", value: "php" }, + { text: "Python", value: "python" }, + { text: "Java", value: "java" }, + { text: "C", value: "c" }, + { text: "C++", value: "cpp" }, + ], + ...featureConfig, + ...(toolbar && { toolbar }), + setup: (editor: any) => { + // ⬅️ Set readOnly di sini + editor.on("init", () => { + if (readOnly) { + editor.mode.set("readonly"); + } + }); + }, + }; + + return ( +
+ + + {/* Status bar */} + {isEditorLoaded && ( +
+
+ + {autoSave && !readOnly ? "Auto-save enabled" : "Read-only mode"} + + {lastSaved && autoSave && !readOnly && ( + • Last saved: {lastSaved.toLocaleTimeString()} + )} + • {wordCount} characters +
+ + {features} mode + +
+ )} + + {/* Performance indicator */} +
+ Bundle size:{" "} + {features === "basic" + ? "~150KB" + : features === "standard" + ? "~200KB" + : "~300KB"} +
+
+ ); +}; + +export default TinyMCEEditor; diff --git a/components/editor/view-editor.js b/components/editor/view-editor.js new file mode 100644 index 0000000..1e579b0 --- /dev/null +++ b/components/editor/view-editor.js @@ -0,0 +1,264 @@ +import React from "react"; +import { CKEditor } from "@ckeditor/ckeditor5-react"; +import Editor from "@/vendor/ckeditor5/build/ckeditor"; + +function ViewEditor(props) { + const maxHeight = props.maxHeight || 600; // Default max height 600px + + return ( +
+ + +
+ ); +} + +export default ViewEditor; + +// import React from "react"; +// import { CKEditor } from "@ckeditor/ckeditor5-react"; +// import Editor from "ckeditor5-custom-build"; + +// function ViewEditor(props) { +// const maxHeight = props.maxHeight || 600; + +// return ( +//
+// +// +//
+// ); +// } + +// export default ViewEditor; diff --git a/components/form/article/create-image-form.tsx b/components/form/article/create-image-form.tsx new file mode 100644 index 0000000..328f946 --- /dev/null +++ b/components/form/article/create-image-form.tsx @@ -0,0 +1,918 @@ +"use client"; +import { Fragment, 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 ReactSelect from "react-select"; +import makeAnimated from "react-select/animated"; +import { convertDateFormatNoTime, 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, + getArticleByCategory, + uploadArticleFile, + uploadArticleThumbnail, +} from "@/service/article"; +import { + saveManualContext, + updateManualArticle, +} from "@/service/generate-article"; +import { getUserLevels } from "@/service/user-levels-service"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { getCategoryById } from "@/service/master-categories"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import GenerateSingleArticleForm from "./generate-ai-single-form"; +import GenerateContentRewriteForm from "./generate-ai-content-rewrite-form"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import DatePicker from "react-datepicker"; + +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(), +}); + +interface DiseData { + id: number; + articleBody: string; + title: string; + metaTitle: string; + description: string; + metaDescription: string; + mainKeyword: string; + additionalKeywords: string; +} + +const createArticleSchema = z.object({ + title: z.string().min(2, { + message: "Judul harus diisi", + }), + customCreatorName: 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", + }), + source: z.enum(["internal", "external"]).optional(), +}); + +export default function CreateImageForm() { + const userLevel = Cookies.get("ulne"); + const animatedComponents = makeAnimated(); + const MySwal = withReactContent(Swal); + const router = useRouter(); + const [files, setFiles] = useState([]); + const [useAi, setUseAI] = useState(false); + const [listCategory, setListCategory] = useState([]); + const [tag, setTag] = useState(""); + const [thumbnailImg, setThumbnailImg] = useState([]); + const [selectedMainImage, setSelectedMainImage] = useState( + null, + ); + const [thumbnailValidation, setThumbnailValidation] = useState(""); + const [filesValidation, setFileValidation] = useState(""); + const [diseData, setDiseData] = useState(); + const [selectedWritingType, setSelectedWritingType] = useState("single"); + const [status, setStatus] = useState<"publish" | "draft" | "scheduled">( + "publish", + ); + const [isScheduled, setIsScheduled] = useState(false); + const [startDateValue, setStartDateValue] = useState(); + const [startTimeValue, setStartTimeValue] = useState(""); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: (acceptedFiles) => { + setFiles((prevFiles) => [ + ...prevFiles, + ...acceptedFiles.map((file) => Object.assign(file)), + ]); + }, + multiple: true, + accept: { + "image/*": [], + }, + }); + + const formOptions = { + resolver: zodResolver(createArticleSchema), + defaultValues: { title: "", description: "", category: [], tags: [] }, + }; + type UserSettingSchema = z.infer; + const { + control, + handleSubmit, + formState: { errors }, + setValue, + getValues, + watch, + setError, + clearErrors, + } = useForm(formOptions); + + 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) => { + if ((thumbnailImg.length < 1 && !selectedMainImage) || files.length < 1) { + if (files.length < 1) { + setFileValidation("Required"); + } else { + setFileValidation(""); + } + if (thumbnailImg.length < 1 && !selectedMainImage) { + setThumbnailValidation("Required"); + } else { + setThumbnailValidation(""); + } + } else { + setThumbnailValidation(""); + setFileValidation(""); + MySwal.fire({ + title: "Simpan Data", + text: "", + icon: "warning", + showCancelButton: true, + cancelButtonColor: "#d33", + confirmButtonColor: "#3085d6", + confirmButtonText: "Simpan", + }).then((result) => { + if (result.isConfirmed) { + save(values); + } + }); + } + }; + + useEffect(() => { + if (useAi === false) { + setValue("description", ""); + } + }, [useAi]); + + 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 saveArticleToDise = async ( + values: z.infer, + ) => { + if (useAi) { + const request = { + id: diseData?.id, + title: values.title, + customCreatorName: values.customCreatorName, + source: values.source, + articleBody: removeImgTags(values.description), + metaDescription: diseData?.metaDescription, + metaTitle: diseData?.metaTitle, + mainKeyword: diseData?.mainKeyword, + additionalKeywords: diseData?.additionalKeywords, + createdBy: "345", + style: "Informational", + projectId: 2, + clientId: "humasClientIdtest", + lang: "id", + }; + const res = await updateManualArticle(request); + if (res.error) { + error(res.message); + return false; + } + + return diseData?.id; + } else { + const request = { + title: values.title, + articleBody: removeImgTags(values.description), + metaDescription: values.title, + metaTitle: values.title, + mainKeyword: values.title, + additionalKeywords: values.title, + createdBy: "345", + style: "Informational", + projectId: 2, + clientId: "humasClientIdtest", + lang: "id", + }; + + const res = await saveManualContext(request); + if (res.error) { + res.message; + return 0; + } + return res?.data?.data?.id; + } + }; + + const getUserLevelApprovalStatus = async () => { + const res = await getUserLevels(String(userLevel)); + return res?.data?.data?.isApprovalActive; + }; + + const save = async (values: z.infer) => { + loading(); + + const userLevelStatus = await getUserLevelApprovalStatus(); + const formData = { + title: values.title, + typeId: 1, + slug: values.slug, + customCreatorName: values.customCreatorName, + source: values.source, + categoryIds: "test", + tags: values.tags.join(","), + description: htmlToString(removeImgTags(values.description)), + htmlDescription: removeImgTags(values.description), + aiArticleId: await saveArticleToDise(values), + // isDraft: userLevelStatus ? true : status === "draft", + // isPublish: userLevelStatus ? false : status === "publish", + isDraft: status === "draft", + isPublish: status === "publish", + }; + + const response = await createArticle(formData); + + if (response?.error) { + error(response.message); + return false; + } + const articleId = response?.data?.data?.id; + + if (files?.length > 0) { + const formFiles = new FormData(); + + for (const element of files) { + formFiles.append("file", element); + const resFile = await uploadArticleFile(articleId, formFiles); + } + } + if (thumbnailImg?.length > 0 || files?.length > 0) { + if (thumbnailImg?.length > 0) { + const formFiles = new FormData(); + + formFiles.append("files", thumbnailImg[0]); + const resFile = await uploadArticleThumbnail(articleId, formFiles); + } else { + const formFiles = new FormData(); + + if (selectedMainImage) { + formFiles.append("files", files[selectedMainImage - 1]); + + const resFile = await uploadArticleThumbnail(articleId, formFiles); + } + } + } + + if (status === "scheduled" && startDateValue) { + // ambil waktu, default 00:00 jika belum diisi + const [hours, minutes] = startTimeValue + ? startTimeValue.split(":").map(Number) + : [0, 0]; + + // gabungkan tanggal + waktu + const combinedDate = new Date(startDateValue); + combinedDate.setHours(hours, minutes, 0, 0); + + // format: 2025-10-08 14:30:00 + const formattedDateTime = `${combinedDate.getFullYear()}-${String( + combinedDate.getMonth() + 1, + ).padStart(2, "0")}-${String(combinedDate.getDate()).padStart( + 2, + "0", + )} ${String(combinedDate.getHours()).padStart(2, "0")}:${String( + combinedDate.getMinutes(), + ).padStart(2, "0")}:00`; + + const request = { + id: articleId, + date: formattedDateTime, + }; + + console.log("📤 Sending schedule request:", request); + const res = await createArticleSchedule(request); + console.log("✅ Schedule response:", res); + } + + close(); + successSubmit("/admin/article", articleId, values.slug); + }; + + function successSubmit(redirect: string, id: number, slug: string) { + const url = + `${window.location.protocol}//${window.location.host}` + + "/news/detail/" + + `${id}-${slug}`; + MySwal.fire({ + title: "Sukses", + icon: "success", + confirmButtonColor: "#3085d6", + confirmButtonText: "OK", + }).then((result) => { + if (result.isConfirmed) { + router.push(redirect); + successToast("Article Url", url); + } else { + router.push(redirect); + successToast("Article Url", url); + } + }); + } + + 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 ( + {file.name} + ); + } 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, index) => ( +
+
+
{renderFilePreview(file)}
+
+
{file.name}
+
+ {Math.round(file.size / 100) / 10 > 1000 ? ( + <>{(Math.round(file.size / 100) / 10000).toFixed(1)} + ) : ( + <>{(Math.round(file.size / 100) / 10).toFixed(1)} + )} + {" kb"} +
+
+ setSelectedMainImage(index + 1)} + /> + +
+
+
+ + +
+ )); + + const handleFileChange = (event: React.ChangeEvent) => { + const selectedFiles = event.target.files; + if (selectedFiles) { + setThumbnailImg(Array.from(selectedFiles)); + } + }; + + // const selectedCategory = watch("category"); + + // useEffect(() => { + // getDetailCategory(); + // }, [selectedCategory]); + + // const getDetailCategory = async () => { + // let temp = getValues("tags"); + // for (const element of selectedCategory) { + // const res = await getCategoryById(element?.id); + // const tagList = res?.data?.data?.tags; + // if (tagList) { + // temp = [...temp, ...res?.data?.data?.tags]; + // } + // } + // const uniqueArray = temp.filter( + // (item, index) => temp.indexOf(item) === index, + // ); + + // setValue("tags", uniqueArray as [string, ...string[]]); + // }; + + return ( +
+
+

Judulss

+ ( + + )} + /> + + {errors?.title && ( +

{errors.title?.message}

+ )} +

Slug

+ ( + + )} + /> + + {errors?.slug && ( +

{errors.slug?.message}

+ )} + +
+ +

Bantuan AI

+
+ + {useAi && ( +
+ + {selectedWritingType === "single" ? ( + { + setDiseData(data); + // setValue("title", data?.title ?? "", { + // shouldValidate: true, + // shouldDirty: true, + // }); + // setValue("slug", generateSlug(data?.title ?? ""), { + // shouldValidate: true, + // shouldDirty: true, + // }); + setValue( + "description", + data?.articleBody ? data?.articleBody : "", + ); + }} + /> + ) : ( + { + setDiseData(data); + setValue( + "description", + data?.articleBody ? data?.articleBody : "", + ); + }} + /> + )} +
+ )} + +

Deskripsi

+ ( + + )} + /> + {errors?.description && ( +

+ {errors.description?.message} +

+ )} + +

File Media

+ +
+ +
+ +

+ Tarik file disini atau klik untuk upload. +

+
+ ( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran + maksimal 100mb.) +
+
+
+ {files.length ? ( + +
{fileList}
+
+ +
+
+ ) : null} +
+ {filesValidation !== "" && files.length < 1 && ( +

Upload File Media

+ )} +
+
+
+

Thubmnail

+ + {selectedMainImage && files.length >= selectedMainImage ? ( +
+ thumbnail + +
+ ) : thumbnailImg.length > 0 ? ( +
+ thumbnail + +
+ ) : ( + <> + {/* {" "} */} + + {thumbnailValidation !== "" && ( +

+ Upload thumbnail atau pilih dari File Media +

+ )} + + )} +

Kreator

+ ( + + )} + /> +
+

Tipe Kreator

+ ( + + )} + /> +
+

Kategori

+ {/* ( + + "!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500", + }} + classNamePrefix="select" + value={value} + onChange={(selected) => { + onChange(selected); + }} + closeMenuOnSelect={false} + components={animatedComponents} + isClearable={true} + isSearchable={true} + isMulti={true} + placeholder="Kategori..." + name="sub-module" + options={listCategory} + /> + )} + /> + {errors?.category && ( +

+ {errors.category?.message} +

+ )} */} + +

Tags

+ + ( +
+ {/* Menampilkan tags */} +
+ {value.map((item: string, index: number) => ( + + {item} + + + ))} +
+ + {/* Textarea input */} +