update form,contact us landing, responsive detail audio

This commit is contained in:
Anang Yusman 2026-02-23 15:47:35 +08:00
parent a0bb9c858c
commit 69cf73ec62
50 changed files with 6221 additions and 193 deletions

View File

@ -0,0 +1,9 @@
import CreateImageForm from "@/components/form/article/create-image-form";
export default function CreateNewsImage() {
return (
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto">
<CreateImageForm />
</div>
);
}

View File

@ -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";

View File

@ -10,32 +10,34 @@ export default function AudioPlayerSection() {
return (
<div className="space-y-6">
{/* ===== AUDIO PLAYER CARD ===== */}
<div className="bg-gray-50 rounded-2xl p-6 border border-gray-200">
<div className="flex items-center gap-6">
<div className="bg-gray-50 rounded-2xl p-4 md:p-6 border border-gray-200">
<div className="flex flex-col md:flex-row md:items-center gap-6">
{/* PLAY BUTTON */}
<button
onClick={() => setPlaying(!playing)}
className="h-16 w-16 rounded-full bg-yellow-400 flex items-center justify-center shadow-md hover:scale-105 transition"
>
{playing ? (
<Pause size={28} className="text-black" />
) : (
<Play size={28} className="text-black ml-1" />
)}
</button>
<div className="flex justify-center md:justify-start">
<button
onClick={() => setPlaying(!playing)}
className="h-14 w-14 md:h-16 md:w-16 rounded-full bg-yellow-400 flex items-center justify-center shadow-md hover:scale-105 transition"
>
{playing ? (
<Pause size={24} className="text-black" />
) : (
<Play size={24} className="text-black ml-1" />
)}
</button>
</div>
{/* WAVEFORM + DURATION */}
<div className="flex-1">
{/* FAKE WAVEFORM */}
<div className="h-16 flex items-center gap-[3px]">
{Array.from({ length: 70 }).map((_, i) => (
<div className="h-14 md:h-16 flex items-center gap-[2px] overflow-hidden">
{Array.from({ length: 60 }).map((_, i) => (
<div
key={i}
className={`w-[3px] rounded-full ${
i < 35 ? "bg-black" : "bg-gray-400"
className={`w-[2px] md:w-[3px] rounded-full ${
i < 30 ? "bg-black" : "bg-gray-400"
}`}
style={{
height: `${Math.random() * 40 + 10}px`,
height: `${Math.random() * 35 + 10}px`,
}}
/>
))}
@ -49,8 +51,7 @@ export default function AudioPlayerSection() {
{/* PROGRESS */}
<div className="flex items-center gap-3 mt-3">
<Volume2 size={16} />
<Volume2 size={16} className="shrink-0" />
<input
type="range"
min={0}
@ -65,8 +66,8 @@ export default function AudioPlayerSection() {
</div>
{/* ===== META INFO ===== */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span className="bg-red-600 text-white text-xs px-2 py-1 rounded">
<div className="flex flex-wrap items-center gap-3 text-xs md:text-sm text-gray-500">
<span className="bg-red-600 text-white text-[10px] md:text-xs px-2 py-1 rounded">
POLRI
</span>
@ -76,13 +77,13 @@ export default function AudioPlayerSection() {
</div>
{/* ===== TITLE ===== */}
<h1 className="text-2xl font-semibold leading-snug">
<h1 className="text-lg md:text-2xl font-semibold leading-snug">
Bharatu Mardi Hadji Gugur Saat Bertugas, Diganjar Kenaikan Pangkat Luar
Biasa
</h1>
{/* ===== ARTICLE ===== */}
<div className="space-y-4 text-gray-700 leading-relaxed text-[15px]">
<div className="space-y-4 text-gray-700 leading-relaxed text-sm md:text-[15px]">
<p>
Jakarta - Kapolri Jenderal Polisi Drs. Listyo Sigit Prabowo memberikan
kenaikan pangkat luar biasa anumerta kepada almarhum Bharatu Mardi

Binary file not shown.

View File

@ -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 (
<div className="ckeditor-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
onChange={(event, editor) => {
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",
},
}}
/>
</div>
);
}
export default CustomEditor;

View File

@ -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<EditorExampleProps> = ({
editorType = 'tinymce'
}) => {
const [content, setContent] = useState('<p>Hello, this is the editor content!</p>');
const [savedContent, setSavedContent] = useState('');
const handleContentChange = (newContent: string) => {
setContent(newContent);
};
const handleSave = () => {
setSavedContent(content);
console.log('Content saved:', content);
};
const handleReset = () => {
setContent('<p>Content has been reset!</p>');
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">Rich Text Editor Example</h2>
<p className="text-gray-600 mb-4">
This is an optimized editor with {editorType} - much smaller bundle size and better performance!
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor Panel */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Editor</h3>
<div className="flex gap-2">
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Reset
</button>
</div>
</div>
<div className="border border-gray-200 rounded-lg">
{/* Choose your editor based on migration */}
{editorType === 'tinymce' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
TinyMCE Editor (200KB bundle)
</p>
{/* <OptimizedEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">TinyMCE Editor Component</p>
</div>
</div>
)}
{editorType === 'ckeditor' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
CKEditor5 Classic (800KB bundle)
</p>
{/* <OptimizedCKEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">CKEditor5 Classic Component</p>
</div>
</div>
)}
{editorType === 'quill' && (
<div className="p-4">
<p className="text-gray-500 text-sm mb-2">
React Quill (100KB bundle)
</p>
{/* <MinimalEditor
initialData={content}
onChange={handleContentChange}
height={400}
placeholder="Start typing your content..."
/> */}
<div className="h-96 bg-gray-50 border border-gray-200 rounded flex items-center justify-center">
<p className="text-gray-500">React Quill Component</p>
</div>
</div>
)}
</div>
</div>
{/* Preview Panel */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Preview</h3>
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Current Content:</h4>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
{savedContent && (
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Saved Content:</h4>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: savedContent }}
/>
</div>
)}
<div className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium mb-2">Raw HTML:</h4>
<pre className="text-xs bg-gray-100 p-2 rounded overflow-auto max-h-32">
{content}
</pre>
</div>
</div>
</div>
{/* Performance Info */}
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Performance Benefits:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> 90% smaller bundle size compared to custom CKEditor5</li>
<li> Faster initial load time</li>
<li> Better mobile performance</li>
<li> Reduced memory usage</li>
<li> Improved Lighthouse score</li>
</ul>
</div>
</div>
);
};
export default EditorExample;

View File

@ -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 = `<p>Updated content at ${new Date().toLocaleTimeString()}</p><p>This content was set via setValue</p>`;
setValue('description', newContent);
setTestData(newContent);
};
const handleSetEmpty = () => {
setValue('description', '');
setTestData('');
};
const handleSetHTML = () => {
const htmlContent = `
<h2>HTML Content Test</h2>
<p>This is a <strong>bold</strong> paragraph with <em>italic</em> text.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
<li>List item 3</li>
</ul>
<p>Updated at: ${new Date().toLocaleTimeString()}</p>
`;
setValue('description', htmlContent);
setTestData(htmlContent);
};
const onSubmit = (data: any) => {
console.log('Form submitted:', data);
alert('Form submitted! Check console for data.');
};
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Editor Test Component</h1>
<Card className="p-4">
<div className="space-y-4">
<div>
<Label>Editor Type:</Label>
<div className="flex gap-2 mt-2">
<Button
variant={editorType === 'custom' ? 'default' : 'outline'}
onClick={() => setEditorType('custom')}
>
CustomEditor
</Button>
<Button
variant={editorType === 'form' ? 'default' : 'outline'}
onClick={() => setEditorType('form')}
>
FormEditor
</Button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={handleSetValue} variant="outline">
Set Value (Current Time)
</Button>
<Button onClick={handleSetEmpty} variant="outline">
Set Empty
</Button>
<Button onClick={handleSetHTML} variant="outline">
Set HTML Content
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Current Test Data:</Label>
<div className="mt-2 p-2 bg-gray-100 rounded text-sm">
{testData || '(empty)'}
</div>
</div>
<div>
<Label>Watched Form Values:</Label>
<div className="mt-2 p-2 bg-gray-100 rounded text-sm">
<pre>{JSON.stringify(watchedValues, null, 2)}</pre>
</div>
</div>
</div>
</div>
</Card>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card className="p-4">
<div className="space-y-4">
<div>
<Label>Title:</Label>
<Controller
control={control}
name="title"
render={({ field }) => (
<Input {...field} className="mt-1" />
)}
/>
</div>
<div>
<Label>Description (Editor):</Label>
<Controller
control={control}
name="description"
render={({ field }) => (
editorType === 'custom' ? (
<CustomEditor
onChange={field.onChange}
initialData={field.value}
/>
) : (
<FormEditor
onChange={field.onChange}
initialData={field.value}
/>
)
)}
/>
</div>
<div>
<Label>Creator Name:</Label>
<Controller
control={control}
name="creatorName"
render={({ field }) => (
<Input {...field} className="mt-1" />
)}
/>
</div>
<Button type="submit" className="w-full">
Submit Form
</Button>
</div>
</Card>
</form>
<Card className="p-4">
<h3 className="font-semibold mb-2">Instructions:</h3>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Switch between CustomEditor and FormEditor to test both</li>
<li>Click "Set Value" to test setValue functionality</li>
<li>Click "Set Empty" to test empty content handling</li>
<li>Click "Set HTML Content" to test rich HTML content</li>
<li>Type in the editor to test onChange functionality</li>
<li>Submit the form to see all data</li>
</ul>
</Card>
</div>
);
}

Binary file not shown.

View File

@ -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 (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
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',
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default FormEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Minimal settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Basic content handling
paste_as_text: false,
paste_enable_default_filters: true,
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default MinimalEditor;

View File

@ -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<OptimizedEditorProps> = ({
initialData = "",
onChange,
height = 400,
placeholder = "Start typing...",
disabled = false,
readOnly = false,
}) => {
const editorRef = useRef<any>(null);
const handleEditorChange = (content: string) => {
if (onChange) {
onChange(content);
}
};
const handleInit = (evt: any, editor: any) => {
editorRef.current = editor;
};
return (
<Editor
onInit={handleInit}
initialValue={initialData}
onEditorChange={handleEditorChange}
disabled={disabled}
init={{
height,
menubar: false,
plugins: [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"code",
"help",
"wordcount",
],
toolbar:
"undo redo | blocks | " +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | table | code | help",
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: ${height - 32}px;
}
`,
placeholder,
readonly: readOnly,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Performance optimizations
cache_suffix: "?v=1.0",
browser_spellcheck: false,
gecko_spellcheck: false,
// Auto-save feature
auto_save: true,
auto_save_interval: "30s",
// Better mobile support
mobile: {
theme: "silver",
plugins: ["lists", "autolink", "link", "image", "table"],
toolbar: "bold italic | bullist numlist | link image",
},
}}
/>
);
};
export default OptimizedEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false, // No toolbar for read-only mode
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body * {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Minimal settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Performance optimizations for read-only
cache_suffix: '?v=1.0',
browser_spellcheck: false,
gecko_spellcheck: false,
// Disable editing features
paste_as_text: true,
paste_enable_default_filters: false,
paste_word_valid_elements: false,
paste_retain_style_properties: false,
// Additional read-only settings
contextmenu: false,
selection: false,
// Disable all editing
object_resizing: false,
element_format: 'html',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: false
}
}}
/>
);
}
export default ReadOnlyEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Better content handling
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',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default SimpleEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.mce-content-body * {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
browser_spellcheck: false,
gecko_spellcheck: false,
paste_as_text: true,
paste_enable_default_filters: false,
contextmenu: false,
selection: false,
object_resizing: false,
element_format: 'html'
}}
/>
);
}
export default SimpleReadOnlyEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings for stability
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable all problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Content handling
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',
// Prevent automatic updates
element_format: 'html',
valid_children: '+body[style]',
extended_valid_elements: 'span[*]',
custom_elements: '~span',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default StableEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: 400,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | table | code | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
}
.mce-content-body {
padding: 16px;
min-height: 368px;
}
`,
placeholder: 'Start typing...',
branding: false,
elementpath: false,
resize: false,
statusbar: false,
// Critical settings to prevent cursor jumping
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
keep_styles: true,
// Disable all problematic features
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
// Content handling
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',
// Prevent automatic updates
element_format: 'html',
valid_children: '+body[style]',
extended_valid_elements: 'span[*]',
custom_elements: '~span',
// Mobile support
mobile: {
theme: 'silver',
plugins: ['lists', 'autolink', 'link', 'image', 'table'],
toolbar: 'bold italic | bullist numlist | link image'
}
}}
/>
);
}
export default StaticEditor;

View File

@ -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 (
<Editor
onInit={handleInit}
initialValue={props.initialData || ''}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={{
height: props.height || 400,
menubar: false,
toolbar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'anchor', 'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'media', 'table'
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
.mce-content-body {
padding: 16px;
min-height: ${(props.height || 400) - 32}px;
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
.mce-content-body * {
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
`,
readonly: true,
branding: false,
elementpath: false,
resize: false,
statusbar: false,
auto_focus: false,
forced_root_block: 'p',
entity_encoding: 'raw',
verify_html: false,
cleanup: false,
cleanup_on_startup: false,
auto_resize: false,
browser_spellcheck: false,
gecko_spellcheck: false,
paste_as_text: true,
paste_enable_default_filters: false,
contextmenu: false,
selection: false,
object_resizing: false,
element_format: 'html',
// Additional strict settings
valid_children: false,
extended_valid_elements: false,
custom_elements: false
}}
/>
);
}
export default StrictReadOnlyEditor;

View File

@ -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<string, string>;
className?: string;
autoSave?: boolean;
autoSaveInterval?: number;
}
const TinyMCEEditor: React.FC<TinyMCEEditorProps> = ({
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<any>(null);
const [isEditorLoaded, setIsEditorLoaded] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(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 (
<div className={`tinymce-editor-container ${className}`}>
<Editor
onInit={handleEditorInit}
initialValue={initialData}
onEditorChange={handleEditorChange}
disabled={disabled}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
init={editorConfig}
/>
{/* Status bar */}
{isEditorLoaded && (
<div className="text-xs text-gray-500 mt-2 flex items-center justify-between">
<div className="flex items-center space-x-4">
<span>
{autoSave && !readOnly ? "Auto-save enabled" : "Read-only mode"}
</span>
{lastSaved && autoSave && !readOnly && (
<span> Last saved: {lastSaved.toLocaleTimeString()}</span>
)}
<span> {wordCount} characters</span>
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{features} mode
</span>
</div>
)}
{/* Performance indicator */}
<div className="text-xs text-gray-400 mt-1">
Bundle size:{" "}
{features === "basic"
? "~150KB"
: features === "standard"
? "~200KB"
: "~300KB"}
</div>
</div>
);
};
export default TinyMCEEditor;

View File

@ -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 (
<div className="ckeditor-view-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
disabled={true}
config={{
isReadOnly: true,
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #111;
background: #fff;
margin: 0;
padding: 0;
}
p {
margin: 0.5em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
}
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;
}
`,
height: props.height || 400,
removePlugins: ["Title"],
}}
/>
<style jsx>{`
.ckeditor-view-wrapper {
border-radius: 6px;
overflow: hidden;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.ckeditor-view-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px;
max-height: ${maxHeight}px;
}
.ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
min-height: ${(props.height || 400) - 50}px;
max-height: ${maxHeight - 50}px;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
background-color: #fdfdfd;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #111;
}
/* 🌙 Dark mode support */
:global(.dark) .ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
background-color: #111 !important;
color: #f9fafb !important;
border-color: #374151;
}
:global(.dark) .ckeditor-view-wrapper h1,
:global(.dark) .ckeditor-view-wrapper h2,
:global(.dark) .ckeditor-view-wrapper h3,
:global(.dark) .ckeditor-view-wrapper h4,
:global(.dark) .ckeditor-view-wrapper h5,
:global(.dark) .ckeditor-view-wrapper h6 {
color: #f9fafb !important;
}
:global(.dark) .ckeditor-view-wrapper blockquote {
background-color: #1f2937 !important;
border-left: 4px solid #374151 !important;
color: #f3f4f6 !important;
}
/* Custom scrollbar styling */
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar) {
width: 8px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #f1f5f9;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #cbd5e1;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #94a3b8;
}
/* 🌙 Dark mode scrollbar */
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #1f2937;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #4b5563;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #6b7280;
}
/* Read-only specific styling */
.ckeditor-view-wrapper :global(.ck.ck-editor__editable.ck-read-only) {
cursor: default;
}
/* Hide toolbar */
.ckeditor-view-wrapper :global(.ck.ck-toolbar) {
display: none !important;
}
`}</style>
</div>
);
}
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 (
// <div className="ckeditor-view-wrapper">
// <CKEditor
// editor={Editor}
// data={props.initialData}
// disabled={true}
// config={{
// // toolbar: [],
// isReadOnly: true,
// // Add content styling configuration for read-only mode
// 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: 0;
// }
// p {
// margin: 0.5em 0;
// }
// h1, h2, h3, h4, h5, h6 {
// margin: 1em 0 0.5em 0;
// }
// 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;
// }
// `,
// // Editor appearance settings
// height: props.height || 400,
// removePlugins: ['Title'],
// }}
// />
// <style jsx>{`
// .ckeditor-view-wrapper {
// border-radius: 6px;
// overflow: hidden;
// box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__main) {
// min-height: ${props.height || 400}px;
// max-height: ${maxHeight}px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
// min-height: ${(props.height || 400) - 50}px;
// max-height: ${maxHeight - 50}px;
// overflow-y: auto !important;
// scrollbar-width: thin;
// scrollbar-color: #cbd5e1 #f1f5f9;
// background-color:rgb(253, 253, 253);
// border: 1px solid #d1d5db;
// border-radius: 6px;
// }
// /* Custom scrollbar styling for webkit browsers */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar) {
// width: 8px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
// background: #f1f5f9;
// border-radius: 4px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
// background: #cbd5e1;
// border-radius: 4px;
// }
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
// background: #94a3b8;
// }
// /* Ensure content doesn't overflow */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable .ck-content) {
// overflow: hidden;
// }
// /* Read-only specific styling */
// .ckeditor-view-wrapper :global(.ck.ck-editor__editable.ck-read-only) {
// background-color: #f8fafc;
// color: #4b5563;
// cursor: default;
// }
// /* Hide toolbar for view-only mode */
// .ckeditor-view-wrapper :global(.ck.ck-toolbar) {
// display: none !important;
// }
// `}</style>
// </div>
// );
// }
// export default ViewEditor;

View File

@ -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<FileWithPreview[]>([]);
const [useAi, setUseAI] = useState(false);
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
const [tag, setTag] = useState("");
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(
null,
);
const [thumbnailValidation, setThumbnailValidation] = useState("");
const [filesValidation, setFileValidation] = useState("");
const [diseData, setDiseData] = useState<DiseData>();
const [selectedWritingType, setSelectedWritingType] = useState("single");
const [status, setStatus] = useState<"publish" | "draft" | "scheduled">(
"publish",
);
const [isScheduled, setIsScheduled] = useState(false);
const [startDateValue, setStartDateValue] = useState<Date | undefined>();
const [startTimeValue, setStartTimeValue] = useState<string>("");
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<typeof createArticleSchema>;
const {
control,
handleSubmit,
formState: { errors },
setValue,
getValues,
watch,
setError,
clearErrors,
} = useForm<UserSettingSchema>(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<typeof createArticleSchema>) => {
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<typeof createArticleSchema>,
) => {
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<typeof createArticleSchema>) => {
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 (
<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, index) => (
<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 className="flex items-center space-x-2">
<Checkbox
id={String(index)}
value={String(index)}
checked={selectedMainImage === index + 1}
onCheckedChange={() => setSelectedMainImage(index + 1)}
/>
<label htmlFor={String(index)} className="text-black text-xs">
Jadikan Thumbnail
</label>
</div>
</div>
</div>
<Button
className="rounded-full"
variant="ghost"
onClick={() => handleRemoveFile(file)}
>
<TimesIcon />
</Button>
</div>
));
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<form
className="flex flex-col lg:flex-row gap-8 text-black"
onSubmit={handleSubmit(onSubmit)}
>
<div className="w-full lg:w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1">
<p className="text-sm">Judulss</p>
<Controller
control={control}
name="title"
render={({ field }) => (
<Input
id="title"
type="text"
placeholder="Masukkan judul artikel"
className="h-16 px-4 text-2xl leading-tight"
{...field}
/>
)}
/>
{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 }) => (
<Input
type="text"
id="title"
placeholder=""
value={field.value ?? ""}
onChange={field.onChange}
className="w-full border rounded-lg dark:border-gray-400"
/>
)}
/>
{errors?.slug && (
<p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
)}
<div className="flex items-center gap-2 mt-3">
<Switch checked={useAi} onCheckedChange={setUseAI} />
<p className="text-sm text-black">Bantuan AI</p>
</div>
{useAi && (
<div className="flex flex-col gap-2">
<Select
value={selectedWritingType ?? ""}
onValueChange={(value) => {
if (value !== "") setSelectedWritingType(value);
}}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Article</SelectItem>
{/* <SelectItem value="rewrite">Content Rewrite</SelectItem> */}
</SelectContent>
</Select>
{selectedWritingType === "single" ? (
<GenerateSingleArticleForm
content={(data) => {
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 : "",
);
}}
/>
) : (
<GenerateContentRewriteForm
content={(data) => {
setDiseData(data);
setValue(
"description",
data?.articleBody ? data?.articleBody : "",
);
}}
/>
)}
</div>
)}
<p className="text-sm mt-3">Deskripsi</p>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<CustomEditor onChange={onChange} initialData={value} />
)}
/>
{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">
<Button onClick={() => setFiles([])} size="sm">
Hapus Semua
</Button>
</div>
</Fragment>
) : null}
</Fragment>
{filesValidation !== "" && files.length < 1 && (
<p className="text-red-400 text-sm mb-3">Upload File Media</p>
)}
</div>
<div className="w-full lg: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>
{selectedMainImage && files.length >= selectedMainImage ? (
<div className="flex flex-row">
<img
src={URL.createObjectURL(files[selectedMainImage - 1])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
className="border-none rounded-full"
variant="outline"
size="sm"
onClick={() => setSelectedMainImage(null)}
>
<TimesIcon />
</Button>
</div>
) : thumbnailImg.length > 0 ? (
<div className="flex flex-row">
<img
src={URL.createObjectURL(thumbnailImg[0])}
className="w-[30%]"
alt="thumbnail"
/>
<Button
className="border-none rounded-full"
variant="outline"
size="sm"
onClick={() => setThumbnailImg([])}
>
<TimesIcon />
</Button>
</div>
) : (
<>
{/* <label htmlFor="file-upload">
<button>Upload Thumbnail</button>
</label>{" "} */}
<input
id="file-upload"
type="file"
multiple
className="w-fit h-fit"
accept="image/*"
onChange={handleFileChange}
/>
{thumbnailValidation !== "" && (
<p className="text-red-400 text-sm mb-3">
Upload thumbnail atau pilih dari File Media
</p>
)}
</>
)}
<p className="text-sm">Kreator</p>
<Controller
control={control}
name="customCreatorName"
render={({ field }) => (
<Input
id="customCreatorName"
type="text"
placeholder="Masukkan judul artikel"
className="w-full border rounded-lg dark:border-gray-400"
{...field}
/>
)}
/>
<div className="mt-2">
<p className="text-sm">Tipe Kreator</p>
<Controller
control={control}
name="source"
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full border rounded-lg text-sm dark:border-gray-400">
<SelectValue placeholder="Pilih tipe kreator" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">Internal</SelectItem>
<SelectItem value="external">External</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<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={(selected) => {
onChange(selected);
}}
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: { value } }) => (
<div className="w-full">
{/* Menampilkan tags */}
<div className="flex flex-wrap gap-1 mb-2">
{value.map((item: string, index: number) => (
<Badge
key={index}
className="flex items-center gap-1 px-2 py-1 text-sm"
variant="secondary"
>
{item}
<button
type="button"
onClick={() => {
const filteredTags = value.filter(
(tag: string) => tag !== item,
);
if (filteredTags.length === 0) {
setError("tags", {
type: "manual",
message: "Tags tidak boleh kosong",
});
} else {
clearErrors("tags");
setValue(
"tags",
filteredTags as [string, ...string[]],
);
}
}}
className="text-red-500 text-xs ml-1"
>
×
</button>
</Badge>
))}
</div>
{/* Textarea input */}
<Textarea
id="tags"
placeholder="Tekan Enter untuk menambahkan tag"
value={tag ?? ""}
onChange={(e) => setTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (tag.trim() !== "") {
setValue("tags", [...value, tag.trim()]);
setTag("");
clearErrors("tags");
}
}
}}
className="border rounded-lg"
aria-label="Tags Input"
/>
</div>
)}
/>
{errors?.tags && (
<p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
)}
<div className="flex flex-col gap-2 mt-3">
<div className="flex items-center space-x-2">
<Switch
id="schedule-switch"
checked={isScheduled}
onCheckedChange={setIsScheduled}
/>
<label htmlFor="schedule-switch" className="text-black text-sm">
Publish dengan Jadwal
</label>
</div>
{isScheduled && (
<div className="flex flex-col lg:flex-row gap-3 mt-2">
{/* Pilih tanggal */}
<div className="w-full lg:w-[140px] flex flex-col gap-2">
<p className="text-sm">Tanggal</p>
<Popover>
<PopoverTrigger>
<Button
type="button"
className="w-full !h-[37px] lg:h-[37px] border-1 rounded-lg text-black"
variant="outline"
>
{startDateValue
? startDateValue.toLocaleDateString("en-CA")
: "-"}
</Button>
</PopoverTrigger>
{/* <PopoverContent className="bg-transparent p-0">
<DatePicker
selected={startDateValue}
onChange={(date) =>
setStartDateValue(date ?? undefined)
}
dateFormat="yyyy-MM-dd"
className="w-full border rounded-lg px-2 py-1 text-black cursor-pointer h-[150px]"
placeholderText="Pilih tanggal"
/>
</PopoverContent> */}
</Popover>
</div>
{/* Pilih waktu */}
<div className="w-full lg:w-[140px] flex flex-col gap-2">
<p className="text-sm">Waktu</p>
<input
type="time"
value={startTimeValue}
onChange={(e) => setStartTimeValue(e.target.value)}
className="w-full border rounded-lg px-2 py-[6px] text-black"
/>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-row justify-end gap-3">
<Button
color="primary"
type="submit"
disabled={isScheduled && startDateValue == null}
onClick={() =>
isScheduled ? setStatus("scheduled") : setStatus("publish")
}
>
Publish
</Button>
<Button
color="success"
type="submit"
onClick={() => setStatus("draft")}
>
<p className="text-white">Draft</p>
</Button>
<Link href="/admin/article">
<Button variant="outline" type="button">
Kembali
</Button>
</Link>
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,323 @@
"use client";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { delay } from "@/utils/global";
import dynamic from "next/dynamic";
import {
getDetailArticle,
getGenerateRewriter,
} from "@/service/generate-article";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import GetSeoScore from "./get-seo-score-form";
const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false }
);
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",
},
];
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
export default function GenerateContentRewriteForm(props: {
content: (data: DiseData) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] =
useState("Informational");
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
const [selectedLanguage, setSelectedLanguage] = useState("id");
const [mainKeyword, setMainKeyword] = useState("");
const [articleIds, setArticleIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number>();
const [isLoading, setIsLoading] = useState(true);
const onSubmit = async () => {
loading();
const request = {
advConfig: "",
context: mainKeyword,
style: selectedWritingSyle,
sentiment: "Informational",
urlContext: null,
contextType: "article",
lang: selectedLanguage,
createdBy: "123123",
clientId: "humasClientIdtest",
};
const res = await getGenerateRewriter(request);
close();
if (res?.error) {
error("Error");
}
setArticleIds([...articleIds, 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;
checkArticleStatus(data?.articleBody);
if (data?.articleBody !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content({
id: data?.id,
articleBody: "",
title: "",
metaTitle: "",
description: "",
metaDescription: "",
additionalKeywords: "",
mainKeyword: "",
});
}
}
};
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
value={selectedWritingSyle}
onValueChange={(value) => setSelectedWritingStyle(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{writingStyle.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</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
value={selectedArticleSize}
onValueChange={(value) => setSelectedArticleSize(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{articleSize.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</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> */}
<Select
value={selectedLanguage}
onValueChange={(value) => setSelectedLanguage(value)}
>
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col mt-3">
<div className="flex flex-row gap-2 items-center">
<p className="text-sm">Text</p>
</div>
<div className="w-[78vw] lg:w-full">
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
</div>
{mainKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)}
{articleIds.length < 3 && (
<Button
onClick={onSubmit}
type="button"
disabled={mainKeyword === "" || isLoading}
className="my-5 w-full py-5 text-xs md:text-base"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
"Generate"
)}
</Button>
)}
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1 mt-2">
{articleIds?.map((id, index) => (
<Button
type="button"
key={id}
onClick={() => setSelectedId(id)}
disabled={isLoading && selectedId === id}
variant={selectedId === id ? "default" : "outline"}
className="flex items-center gap-2"
>
{isLoading && selectedId === id ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
`Article ${index + 1}`
)}
</Button>
))}
</div>
)}
{!isLoading && (
<div>
<GetSeoScore id={String(selectedId)} />
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,451 @@
"use client";
import { useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { delay } from "@/utils/global";
import GetSeoScore from "./get-seo-score-form";
import {
generateDataArticle,
getDetailArticle,
getGenerateKeywords,
getGenerateTitle,
} from "@/service/generate-article";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
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",
},
];
interface DiseData {
id: number;
articleBody: string;
title: string;
metaTitle: string;
description: string;
metaDescription: string;
mainKeyword: string;
additionalKeywords: string;
}
export default function GenerateSingleArticleForm(props: {
content: (data: DiseData) => void;
}) {
const [selectedWritingSyle, setSelectedWritingStyle] = useState("");
const [selectedArticleSize, setSelectedArticleSize] = useState("");
const [selectedLanguage, setSelectedLanguage] = useState("");
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(false);
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",
};
const res = await generateDataArticle(request);
close();
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;
checkArticleStatus(data?.articleBody);
if (data?.articleBody !== null) {
setIsLoading(false);
props.content(data);
} else {
setIsLoading(true);
props.content({
id: data?.id,
articleBody: "",
title: "",
metaTitle: "",
description: "",
metaDescription: "",
additionalKeywords: "",
mainKeyword: "",
});
}
}
};
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
value={selectedWritingSyle}
onValueChange={(value) => {
if (value !== "") setSelectedWritingStyle(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Writing Style" />
</SelectTrigger>
<SelectContent>
{writingStyle.map((style) => (
<SelectItem key={style.name} value={style.name}>
{style.name}
</SelectItem>
))}
</SelectContent>
</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
value={selectedArticleSize}
onValueChange={(value) => {
if (value !== "") setSelectedArticleSize(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Article Size" />
</SelectTrigger>
<SelectContent>
{articleSize.map((style) => (
<SelectItem key={style.name} value={style.value}>
{style.name}
</SelectItem>
))}
</SelectContent>
</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> */}
<Select
value={selectedLanguage}
onValueChange={(value) => {
if (value !== "") setSelectedLanguage(value);
}}
>
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</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
type="button"
variant="default"
size="sm"
onClick={() => generateAll(mainKeyword)}
disabled={isLoading} // tambahkan state kontrol loading
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
"Process"
)}
</Button>
</div>
<Input
type="text"
id="mainKeyword"
placeholder="Masukkan keyword utama"
value={mainKeyword}
onChange={(e) => setMainKeyword(e.target.value)}
className="w-full mt-1 border border-gray-300 rounded-lg 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
type="button"
variant="default"
size="sm"
onClick={() => generateTitle(mainKeyword)}
disabled={mainKeyword === ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="title"
placeholder=""
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full mt-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" // Custom styling using className
aria-label="Title"
/>
{/* {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
type="button"
className="text-sm"
size="sm"
onClick={() => generateKeywords(mainKeyword)}
disabled={mainKeyword === ""}
>
Generate
</Button>
</div>
<Input
type="text"
id="additionalKeyword"
placeholder=""
value={additionalKeyword}
onChange={(e) => setAdditionalKeyword(e.target.value)}
className="mt-1 border rounded-lg dark:bg-transparent dark:border-gray-400"
aria-label="Additional Keyword"
/>
{/* {additionalKeyword == "" && (
<p className="text-red-400 text-sm">Required</p>
)} */}
{/* {articleIds.length < 3 && (
<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>
)} */}
{articleIds.length < 3 && (
<Button
className="my-5 w-full py-5 text-xs md:text-base"
type="button"
onClick={onSubmit}
disabled={
mainKeyword === "" || title === "" || additionalKeyword === ""
}
>
Generate
</Button>
)}
</div>
{articleIds.length > 0 && (
<div className="flex flex-row gap-1 mt-2">
{articleIds.map((id, index) => (
<Button
type="button"
key={id}
onClick={() => setSelectedId(id)}
disabled={isLoading && selectedId === id}
className={`
${
selectedId === id
? isLoading
? "bg-yellow-500"
: "bg-green-600"
: "bg-gray-200"
}
text-sm px-4 py-2 rounded text-white transition-colors
`}
>
Article {index + 1}
</Button>
))}
</div>
)}
{!isLoading && (
<div>
<GetSeoScore id={String(selectedId)} />
</div>
)}
</form>
</fieldset>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { CustomCircularProgress } from "@/components/layout/costum-circular-progress";
import { getSeoScore } from "@/service/generate-article";
import { useEffect, useState } from "react";
export default function GetSeoScore(props: { id: string }) {
useEffect(() => {
fetchSeoScore();
}, [props.id]);
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(props?.id);
if (res.error) {
// error(res.message);
return false;
}
setTotalScoreSEO(res.data.data?.seo_analysis?.score || 0);
const errorList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.error,
...res.data.data?.seo_analysis?.analysis?.content_quality?.error,
];
setErrorSEO(errorList);
const warningList: any[] = [
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.warning,
...res.data.data?.seo_analysis?.analysis?.content_quality?.warning,
];
setWarningSEO(warningList);
const 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>
{totalScoreSEO ? (
<div className="flex flex-row gap-5 w-full">
{/* <CircularProgress
aria-label=""
color="warning"
showValueLabel={true}
size="lg"
value={Number(totalScoreSEO) * 100}
/> */}
<CustomCircularProgress 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>
) : (
"Belum ada Data"
)}
{totalScoreSEO && (
// <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>
<Accordion type="multiple" className="w-full">
<AccordionItem value="error">
<AccordionTrigger className="text-black">{`${
errorSEO?.length || 0
} Errors`}</AccordionTrigger>
<AccordionContent>
<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>
</AccordionContent>
</AccordionItem>
<AccordionItem value="warning">
<AccordionTrigger className="text-black">{`${
warningSEO?.length || 0
} Warnings`}</AccordionTrigger>
<AccordionContent>
<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>
</AccordionContent>
</AccordionItem>
<AccordionItem value="optimized">
<AccordionTrigger className="text-black">{`${
optimizedSEO?.length || 0
} Optimized`}</AccordionTrigger>
<AccordionContent>
<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>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,7 @@
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
export default function AboutSection() {
const socials = [
@ -9,37 +12,56 @@ export default function AboutSection() {
{ name: "Tiktok", icon: "/image/tt.png" },
];
const messages = [
{ id: 1, text: "Dimana posisi Ayah saya sekarang?", type: "user" },
{
id: 2,
text: "Bapak Ahmad terdeteksi di Tenda Maktab 45, Mina.",
type: "bot",
},
{ id: 3, text: "Apakah ada berita cuaca hari ini?", type: "user" },
{
id: 4,
text: "Makkah saat ini cerah, suhu 38°. Kemenag menghimbau jamaah untuk minum air tiap 1 jam.",
type: "bot",
},
];
return (
<section className="relative bg-[#f7f0e3] py-24">
{/* TOP CENTER CONTENT */}
<div className="absolute left-1/2 top-8 flex -translate-x-1/2 flex-col items-center gap-6">
<p className="text-sm font-semibold uppercase tracking-widest text-gray-400">
Manage All your channels from Multipool
</p>
{/* SOCIAL ICONS */}
<div className="flex gap-6">
{socials.map((item) => (
<div
key={item.name}
className="flex items-center justify-center rounded-full"
>
<Image src={item.icon} alt={item.name} width={40} height={40} />
</div>
))}
</div>
</div>
<div className="container mx-auto grid grid-cols-1 items-center gap-16 px-6 md:grid-cols-2">
{/* PHONE IMAGE */}
{/* PHONE WRAPPER */}
<div className="flex justify-center">
<Image
src="/image/phone.png"
alt="App Preview"
width={320}
height={640}
className="object-contain"
/>
<div className="relative w-[320px] h-[640px]">
{/* PHONE IMAGE */}
<Image
src="/image/phone.png"
alt="App Preview"
fill
className="object-contain z-10 pointer-events-none"
/>
{/* CHAT AREA */}
<div className="absolute top-[120px] left-[25px] right-[25px] bottom-[120px] overflow-hidden z-0">
<div className="flex flex-col gap-4">
{messages.map((msg, index) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 1.2 }}
className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm shadow ${
msg.type === "user"
? "bg-blue-600 text-white self-end rounded-br-sm"
: "bg-gray-200 text-gray-800 self-start rounded-bl-sm"
}`}
>
{msg.text}
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* TEXT CONTENT */}
@ -58,14 +80,7 @@ export default function AboutSection() {
<p className="text-sm leading-relaxed text-gray-600">
PT Qudo Buana Nawakara adalah perusahaan nasional Indonesia yang
berfokus pada pengembangan aplikasi untuk mendukung kegiatan
reputasi manajemen institusi, organisasi dan publik figur. Dengan
dukungan teknologi otomatisasi dan kecerdasan buatan (AI) untuk
mengoptimalkan proses. Perusahaan didukung oleh team SDM nasional
yang sudah berpengalaman serta memiliki sertifikasi internasional,
untuk memastikan produk yang dihasilkan handal dan berkualitas
tinggi. PT Qudo Buana Nawakara berkantor pusat di Jakarta dengan
support office di Bandung, Indonesia India USA Oman.
berfokus pada pengembangan aplikasi...
</p>
</div>
</div>

View File

@ -4,66 +4,82 @@ import Image from "next/image";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Menu, X, Home, Box, Briefcase, Newspaper } from "lucide-react";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
export default function Header() {
const [open, setOpen] = useState(false);
const [contactOpen, setContactOpen] = useState(false);
return (
<header className="relative w-full bg-white overflow-hidden">
<aside
className={`fixed right-0 top-0 z-50 h-full w-[280px] bg-[#966314] text-white transition-transform duration-300 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/20 p-6">
<div className="flex rounded-full bg-white text-sm font-semibold text-[#966314]">
<button className="rounded-full bg-white px-3 py-1">ID</button>
<button className="px-3 py-1">EN</button>
<>
<header className="relative w-full bg-white overflow-hidden">
{/* SIDEBAR */}
<aside
className={`fixed right-0 top-0 z-50 h-full w-[280px] bg-[#966314] text-white transition-transform duration-300 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/20 p-6">
<div className="flex rounded-full bg-white text-sm font-semibold text-[#966314]">
<button className="rounded-full bg-white px-3 py-1">ID</button>
<button className="px-3 py-1">EN</button>
</div>
<button onClick={() => setOpen(false)}>
<X />
</button>
</div>
<button onClick={() => setOpen(false)}>
<X />
</button>
<nav className="flex flex-col gap-6 p-6 text-sm font-medium">
<MenuItem icon={<Home size={18} />} label="Home" />
<MenuItem icon={<Box size={18} />} label="Product" />
<MenuItem icon={<Briefcase size={18} />} label="Services" />
<MenuItem
icon={<Newspaper size={18} />}
label="News and Services"
/>
</nav>
</aside>
{/* HERO */}
<div className="container mx-auto flex min-h-[90vh] items-center px-6">
<div className="flex-1 space-y-6">
<h1 className="text-4xl font-extrabold leading-tight md:text-6xl">
<span className="relative inline-block">
<span className="absolute bottom-1 left-0 h-3 w-full bg-[#966314]"></span>
<span className="relative">Beyond Expectations</span>
</span>
<br />
Build <span className="text-[#966314]">Reputation.</span>
</h1>
<Button
size="lg"
onClick={() => setContactOpen(true)}
className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]"
>
Contact Us
</Button>
</div>
<div className="relative hidden flex-1 justify-end md:flex">
<Image
src="/image/img1.png"
alt="Illustration"
width={520}
height={520}
className="object-contain"
/>
</div>
</div>
</header>
<nav className="flex flex-col gap-6 p-6 text-sm font-medium">
<MenuItem icon={<Home size={18} />} label="Home" />
<MenuItem icon={<Box size={18} />} label="Product" />
<MenuItem icon={<Briefcase size={18} />} label="Services" />
<MenuItem icon={<Newspaper size={18} />} label="News and Services" />
</nav>
</aside>
<div className="container mx-auto flex min-h-[90vh] items-center px-6">
<div className="flex-1 space-y-6">
<h1 className="text-4xl font-extrabold leading-tight md:text-6xl">
<span className="relative inline-block">
<span className="absolute bottom-1 left-0 h-3 w-full bg-[#966314]"></span>
<span className="relative">Beyond Expectations</span>
</span>
<br />
Build <span className="text-[#966314]">Reputation.</span>
</h1>
<Button
size="lg"
className="rounded-full bg-[#966314] px-8 py-6 text-base hover:bg-[#7c520f]"
>
Contact Us
</Button>
</div>
<div className="relative hidden flex-1 justify-end md:flex">
<Image
src="/image/img1.png"
alt="Illustration"
width={520}
height={520}
className="object-contain"
/>
</div>
</div>
</header>
{/* CONTACT MODAL */}
{contactOpen && <ContactDialog onClose={() => setContactOpen(false)} />}
</>
);
}
@ -75,3 +91,140 @@ function MenuItem({ icon, label }: { icon: React.ReactNode; label: string }) {
</div>
);
}
function ContactDialog({ onClose }: { onClose: () => void }) {
const [contactMethod, setContactMethod] = useState("office");
return (
<div className="fixed inset-0 z-[999] bg-black/40 backdrop-blur-sm flex items-end md:items-center justify-center">
{/* CONTAINER */}
<div
className="
w-full
h-[90vh] md:h-auto
md:max-w-2xl
bg-white
rounded-t-3xl md:rounded-2xl
p-5 md:p-8
shadow-2xl
relative
overflow-y-auto
"
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-4 top-4 md:right-6 md:top-6 text-gray-500 hover:text-black"
>
<X size={20} />
</button>
{/* Header */}
<h2 className="text-xl md:text-2xl font-bold text-[#966314] mb-2">
Contact Us
</h2>
<p className="text-sm md:text-base text-gray-500 mb-6">
Select a contact method and fill in your personal information. We will
get back to you shortly.
</p>
{/* Contact Method */}
<div className="space-y-3 md:space-y-4 mb-6">
<RadioGroup
value={contactMethod}
onValueChange={setContactMethod}
className="space-y-3"
>
{/* Option 1 */}
<div
className={`flex items-start space-x-3 rounded-xl p-4 cursor-pointer border transition ${
contactMethod === "office" ? "border-[#966314]" : "border-muted"
}`}
>
<RadioGroupItem value="office" id="office" className="mt-1" />
<div className="space-y-1">
<Label htmlFor="office" className="font-medium cursor-pointer">
Office Presentation
</Label>
<p className="text-sm text-muted-foreground">
Our team will come to your office for a presentation.
</p>
</div>
</div>
{/* Option 2 */}
<div
className={`flex items-start space-x-3 rounded-xl p-4 cursor-pointer border transition ${
contactMethod === "hais" ? "border-[#966314]" : "border-muted"
}`}
>
<RadioGroupItem value="hais" id="hais" className="mt-1" />
<div className="space-y-1">
<Label htmlFor="hais" className="font-medium cursor-pointer">
Via HAIs
</Label>
<p className="text-sm text-muted-foreground">
Online consultation through HAIs platform.
</p>
</div>
</div>
</RadioGroup>
</div>
{/* Form */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fullName">
Full Name <span className="text-red-500">*</span>
</Label>
<Input id="fullName" placeholder="Enter full name" />
</div>
<div className="space-y-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" type="email" placeholder="email@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">
Phone Number <span className="text-red-500">*</span>
</Label>
<Input id="phone" placeholder="08xx xxxx xxxx" />
</div>
<div className="space-y-2">
<Label htmlFor="company">
Company Name <span className="text-red-500">*</span>
</Label>
<Input id="company" placeholder="PT. Example Company" />
</div>
</div>
<div className="space-y-2 mt-4">
<Label htmlFor="message">Message / Requirement</Label>
<Textarea
id="message"
placeholder="Describe your needs or questions..."
className="min-h-[120px]"
/>
</div>
{/* Buttons */}
<div className="mt-6 flex flex-col-reverse md:flex-row gap-3 md:justify-end">
<button
onClick={onClose}
className="w-full md:w-auto rounded-xl border px-6 py-3 hover:bg-gray-100"
>
Cancel
</button>
<button className="w-full md:w-auto rounded-xl bg-[#966314] px-6 py-3 text-white hover:bg-[#7c520f]">
Send Request
</button>
</div>
</div>
</div>
);
}

View File

@ -11,7 +11,15 @@ export type OptionProps = {
active?: boolean;
};
const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: OptionProps) => {
const Option = ({
Icon,
title,
selected,
setSelected,
open,
notifs,
active,
}: OptionProps) => {
const [hovered, setHovered] = useState(false);
const isActive = active ?? selected === title;
@ -22,8 +30,8 @@ const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: Op
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative flex h-12 w-full px-3 items-center rounded-xl transition-all duration-200 cursor-pointer group ${
isActive
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
isActive
? "bg-gradient-to-r from-[#966314] to-[#966314] text-white shadow-lg shadow-emerald-500/25"
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800"
}`}
whileHover={{ scale: 1.02 }}
@ -40,27 +48,29 @@ const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: Op
/>
)}
<motion.div
layout
<motion.div
layout
className={`h-full flex items-center justify-center ${
open ? "w-12" : "w-full"
}`}
>
<div className={`text-lg transition-all duration-200 ${
isActive
? "text-white"
: "text-slate-500 group-hover:text-slate-700"
}`}>
<div
className={`text-lg transition-all duration-200 ${
isActive
? "text-white"
: "text-slate-500 group-hover:text-slate-700"
}`}
>
<Icon />
</div>
</motion.div>
{open && (
<motion.span
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
<motion.span
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
className={`text-sm font-medium transition-colors duration-200 ${
isActive ? "text-white" : "text-slate-700"
}`}
@ -88,14 +98,12 @@ const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: Op
{/* Notification badge */}
{notifs && open && (
<motion.span
initial={{ scale: 0, opacity: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, type: "spring" }}
<motion.span
initial={{ scale: 0, opacity: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, type: "spring" }}
className={`absolute right-3 top-1/2 -translate-y-1/2 size-5 rounded-full text-xs font-semibold flex items-center justify-center ${
isActive
? "bg-white text-emerald-500"
: "bg-red-500 text-white"
isActive ? "bg-white text-emerald-500" : "bg-red-500 text-white"
}`}
>
{notifs}

View File

@ -383,9 +383,6 @@ const SidebarContent = ({
);
}
// =============================
// NORMAL ITEM
// =============================
return (
<Link href={item.link!} key={item.title}>
<Option

View File

@ -470,18 +470,18 @@ export default function DashboardContainer() {
</div>
{/* RIGHT - QUICK ACTIONS */}
<div className="bg-amber-800 rounded-2xl shadow p-6 text-white space-y-4">
<div className="bg-[#966314] rounded-2xl shadow p-6 text-white space-y-4">
<h2 className="text-lg font-semibold">Quick Actions</h2>
<button className="w-full border border-amber-600 bg-amber-700 hover:bg-amber-600 transition py-3 rounded-xl text-sm font-medium">
<button className="w-full border border-white bg-[#966314] hover:bg-[#966314] transition py-3 rounded-xl text-sm font-medium">
+ Create New Article
</button>
<button className="w-full border border-amber-600 bg-amber-700 hover:bg-amber-600 transition py-3 rounded-xl text-sm font-medium">
<button className="w-full border border-white bg-[#966314] hover:bg-[#966314] transition py-3 rounded-xl text-sm font-medium">
+ Update Product
</button>
<button className="w-full border border-amber-600 bg-amber-700 hover:bg-amber-600 transition py-3 rounded-xl text-sm font-medium">
<button className="w-full border border-white bg-[#966314] hover:bg-[#966314] transition py-3 rounded-xl text-sm font-medium">
+ Upload Media
</button>

View File

@ -14,6 +14,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Search, Filter, Eye, Pencil, Trash2, Plus } from "lucide-react";
import Link from "next/link";
export default function NewsImage() {
const [activeCategory, setActiveCategory] = useState("All");
@ -97,11 +98,12 @@ export default function NewsImage() {
Create and manage news articles and blog posts
</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
<Plus className="w-4 h-4 mr-2" />
Create New Article
</Button>
<Link href={"/admin/news-article/image/create"}>
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
<Plus className="w-4 h-4 mr-2" />
Create New Article
</Button>
</Link>
</div>
{/* ================= CATEGORY FILTER ================= */}

603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,9 @@
"dependencies": {
"@ckeditor/ckeditor5-react": "^11.0.1",
"@ckeditor/ckeditor5-watchdog": "^47.5.0",
"@hookform/resolvers": "^5.2.2",
"@iconify/iconify": "^3.1.1",
"@iconify/react": "^6.0.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
@ -23,14 +23,20 @@
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tinymce/tinymce-react": "^6.2.1",
"@types/js-cookie": "^3.0.6",
"apexcharts": "^4.7.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"framer-motion": "^12.33.0",
"js-cookie": "^3.0.5",
"lightningcss": "^1.30.1",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"radix-ui": "^1.4.3",
@ -38,6 +44,8 @@
"react-datepicker": "^9.1.0",
"react-day-picker": "^9.13.2",
"react-dom": "19.2.3",
"react-dropzone": "^14.3.8",
"react-select": "^5.10.2",
"sweetalert2": "^11.26.18",
"sweetalert2-react-content": "^5.1.1",
"tailwind-merge": "^3.4.0"

19
service/activity-log.ts Normal file
View File

@ -0,0 +1,19 @@
import { httpGet, httpPost } from "./http-config/http-base-services";
export async function saveActivity(data: any, token?: string) {
const headers = token
? {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
}
: {
"content-type": "application/json",
};
const pathUrl = `/activity-logs`;
return await httpPost(pathUrl, data, headers);
}
export async function getActivity() {
const pathUrl = `/activity-logs/statistics`;
return await httpGet(pathUrl);
}

49
service/advertisement.ts Normal file
View File

@ -0,0 +1,49 @@
import Cookies from "js-cookie";
import {
httpDeleteInterceptor,
httpPostInterceptor,
httpPutInterceptor,
} from "./http-config/http-interceptor-services";
import { httpGet } from "./http-config/http-base-services";
const token = Cookies.get("access_token");
export async function createAdvertise(data: any) {
const pathUrl = `/advertisement`;
return await httpPostInterceptor(pathUrl, data);
}
export async function createMediaFileAdvertise(id: string | number, data: any) {
const headers = {
"Content-Type": "multipart/form-data",
};
const pathUrl = `/advertisement/upload/${id}`;
return await httpPostInterceptor(pathUrl, data, headers);
}
export async function getAdvertise(data: any) {
const pathUrl = `/advertisement?page=${data?.page || 1}&limit=${
data?.limit || ""
}&placement=${data?.placement || ""}&isPublish=${data.isPublish || ""}`;
return await httpGet(pathUrl);
}
export async function getAdvertiseById(id: number) {
const pathUrl = `/advertisement/${id}`;
return await httpGet(pathUrl);
}
export async function editAdvertise(data: any) {
const pathUrl = `/advertisement/${data?.id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function editAdvertiseIsActive(data: any) {
const pathUrl = `/advertisement/publish/${data?.id}?isPublish=${data?.isActive}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function deleteAdvertise(id: number) {
const pathUrl = `/advertisement/${id}`;
return await httpDeleteInterceptor(pathUrl);
}

189
service/article.ts Normal file
View File

@ -0,0 +1,189 @@
import { PaginationRequest } from "@/types/globals";
import { httpGet } from "./http-config/http-base-services";
import {
httpDeleteInterceptor,
httpGetInterceptor,
httpPostInterceptor,
httpPutInterceptor,
} from "./http-config/http-interceptor-services";
export async function getListArticle(props: PaginationRequest) {
const {
page,
limit,
search,
startDate,
endDate,
isPublish,
category,
sortBy,
sort,
categorySlug,
isBanner,
} = props;
return await httpGet(
`/articles?limit=${limit}&page=${page}&isPublish=${
isPublish === undefined ? "" : isPublish
}&title=${search}&startDate=${startDate || ""}&endDate=${
endDate || ""
}&categoryId=${category || ""}&sortBy=${sortBy || "created_at"}&sort=${
sort || "desc"
}&category=${categorySlug || ""}&isBanner=${isBanner || ""}`,
null
);
}
export async function getArticlePagination(props: PaginationRequest) {
const {
page,
limit,
search,
startDate,
endDate,
category,
sortBy,
sort,
categorySlug,
isBanner,
isPublish,
source,
} = props;
return await httpGetInterceptor(
`/articles?limit=${limit}&page=${page}&title=${search}&startDate=${
startDate || ""
}&endDate=${endDate || ""}&categoryId=${category || ""}&source=${
source || ""
}&isPublish=${isPublish !== undefined ? isPublish : ""}&sortBy=${
sortBy || "created_at"
}&sort=${sort || "asc"}&category=${categorySlug || ""}&isBanner=${
isBanner || ""
}`
);
}
export async function getTopArticles(props: PaginationRequest) {
const { page, limit, search, startDate, endDate, isPublish, category } =
props;
const headers = {
"content-type": "application/json",
};
return await httpGet(
`/articles?limit=${limit}&page=${page}&isPublish=${
isPublish === undefined ? "" : isPublish
}&title=${search}&startDate=${startDate || ""}&endDate=${
endDate || ""
}&category=${category || ""}&sortBy=view_count&sort=desc`,
headers
);
}
export async function createArticle(data: any) {
const pathUrl = `/articles`;
return await httpPostInterceptor(pathUrl, data);
}
export async function createArticleSchedule(data: any) {
const pathUrl = `/articles/publish-scheduling?id=${data.id}&date=${data.date}`;
return await httpPostInterceptor(pathUrl, data);
}
export async function unPublishArticle(id: string, data: any) {
const pathUrl = `/articles/${id}/unpublish`;
return await httpPutInterceptor(pathUrl, data);
}
export async function updateArticle(id: string, data: any) {
const pathUrl = `/articles/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function getArticleById(id: any) {
const headers = {
"content-type": "application/json",
};
return await httpGet(`/articles/${id}`, headers);
}
export async function getArticleBySlug(slug: any) {
const headers = {
"content-type": "application/json",
};
return await httpGet(`/articles/slug/${slug}`, headers);
}
export async function deleteArticle(id: string) {
const headers = {
"content-type": "application/json",
};
return await httpDeleteInterceptor(`articles/${id}`, headers);
}
export async function getArticleByCategory() {
return await httpGetInterceptor(`/article-categories?limit=1000`);
}
export async function getCategoryPagination(data: any) {
return await httpGet(
`/article-categories?limit=${data?.limit}&page=${data?.page}&title=${data?.search}`
);
}
export async function uploadArticleFile(id: string, data: any) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpPostInterceptor(`/article-files/${id}`, data, headers);
}
export async function getArticleFiles() {
const headers = {
"content-type": "application/json",
};
return await httpGet(`/article-files`, headers);
}
export async function uploadArticleThumbnail(id: string, data: any) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpPostInterceptor(`/articles/thumbnail/${id}`, data, headers);
}
export async function deleteArticleFiles(id: number) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpDeleteInterceptor(`article-files/${id}`, headers);
}
export async function getUserLevelDataStat(startDate: string, endDate: string) {
return await httpGet(
`/articles/statistic/user-levels?startDate=${startDate}&endDate=${endDate}`
);
}
export async function getStatisticMonthly(year: string) {
return await httpGet(`/articles/statistic/monthly?year=${year}`);
}
export async function getStatisticMonthlyFeedback(year: string) {
return await httpGet(`/feedbacks/statistic/monthly?year=${year}`);
}
export async function getStatisticSummary() {
return await httpGet(`/articles/statistic/summary`);
}
export async function submitApproval(data: {
articleId: number;
message: string;
statusId: number;
}) {
return await httpPostInterceptor(`/article-approvals`, data);
}
export async function updateIsBannerArticle(id: number, status: boolean) {
const headers = {
"content-type": "application/json",
};
const pathUrl = `/articles/banner/${id}?isBanner=${status}`;
return await httpPutInterceptor(pathUrl, headers);
}

264
service/generate-article.ts Normal file
View File

@ -0,0 +1,264 @@
import { httpGet, httpPost, httpPost2 } from "./http-config/disestages-services";
interface GenerateKeywordsAndTitleRequestData {
keyword: string;
style: string;
website: string;
connectToWeb: boolean;
lang: string;
pointOfView: string;
clientId: string;
}
interface GenerateArticleRequest {
advConfig: string;
style: string;
website: string;
connectToWeb: boolean;
lang: string;
pointOfView: string;
title: string;
imageSource: string;
mainKeyword: string;
additionalKeywords: string;
targetCountry: null;
articleSize: string;
projectId: number;
createdBy: string;
clientId: string;
}
type BulkArticleRequest = {
style: string;
website: string;
connectToWeb: boolean;
lang: string;
pointOfView: string;
imageSource: string;
targetCountry: null;
articleSize: string;
projectId: number;
data: { title: string; mainKeyword: string; additionalKeywords: string }[];
createdBy: string;
clientId: string;
};
interface ContentRewriteRequest {
advConfig: string;
context: string | null;
style: string;
sentiment: string;
clientId: string;
createdBy: string;
contextType: string;
urlContext: string | null;
lang: string;
}
export type ContentBankRequest = {
query: string;
page: number;
userId: string;
limit: number;
status: number[];
isSingle: boolean;
createdBy: string;
sort: { column: string; sort: string }[];
};
export async function getGenerateTitle(
data: GenerateKeywordsAndTitleRequestData
) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/generate-title", headers, data);
}
export async function getGenerateKeywords(
data: GenerateKeywordsAndTitleRequestData
) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/generate-keywords", headers, data);
}
export async function generateDataArticle(data: GenerateArticleRequest) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/save-article", headers, data);
}
export async function approveArticle(props: { id: number[] }) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/approve-article", headers, props);
}
export async function rejectArticle(props: { id: number[] }) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/reject-article", headers, props);
}
export async function getGenerateTopicKeywords(data: {
keyword: string;
count: number;
}) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/generate-topic-keywords", headers, data);
}
export async function saveBulkArticle(data: BulkArticleRequest) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/save-bulk-article", headers, data);
}
export async function getDetailArticle(id: number) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpGet(`ai-writer/article/findArticleById/${id}`, headers);
}
export async function generateSpeechToText(data: any) {
const headers = {
"content-type": "multipart/form-data",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/speech-to-text", headers, data);
}
export async function getTranscriptById(id: number) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpGet(`ai-writer/speech-to-text/findById/${id}`, headers);
}
export async function getGenerateRewriter(data: ContentRewriteRequest) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/create-rewriter", headers, data);
}
export async function getListTranscript(data: any) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/speech-to-text/datatable", headers, data);
}
export async function getListArticleDraft(data: ContentBankRequest) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/article/datatable", headers, data);
}
export async function updateManualArticle(data: any) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/update-article", headers, data);
}
export async function getSeoScore(id: string) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpGet(`ai-writer/article/checkSEOScore/${id}`, headers);
}
export async function regenerateArticle(id: number | string) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpGet(`ai-writer/re-create-article/${id}`, headers);
}
export async function saveManualContext(data: any) {
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost("ai-writer/create-article", headers, data);
}
export async function facebookHumasData() {
const data = {
monitoringId: "f33b666c-ac07-4cd6-a64e-200465919e8c",
page: 133,
limit: 10,
userId: "0qrwedt9EcuLyOiBUbqhzjd0BwGRjDBd",
};
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost2(
"monitoring-media-social/datatable/facebook",
headers,
data
);
}
export async function tiktokHumasData() {
const data = {
monitoringId: "1e301867-9599-4d82-ab57-9f7931f96903",
page: 1,
limit: 10,
userId: "0qrwedt9EcuLyOiBUbqhzjd0BwGRjDBd",
};
const headers = {
"content-type": "application/json",
Authorization:
"Basic bmdETFBQaW9ycGx6bncyalRxVmUzWUZDejV4cUtmVUo6UHJEaERXUmNvdkJSNlc1Sg==",
};
return await httpPost2(
"monitoring-media-social/datatable/tiktok",
headers,
data
);
}

View File

@ -0,0 +1,13 @@
import axios from "axios";
const baseURL = "https://qudo.id/api";
const axiosBaseInstance = axios.create({
baseURL,
headers: {
"Content-Type": "application/json",
"X-Client-Key": "629293dd-69cc-4904-a545-23deef377bd9",
},
});
export default axiosBaseInstance;

View File

@ -0,0 +1,72 @@
import axios from "axios";
import { postSignIn } from "../master-user";
import Cookies from "js-cookie";
const baseURL = "https://qudo.id/api";
const refreshToken = Cookies.get("refresh_token");
const axiosInterceptorInstance = axios.create({
baseURL,
headers: {
"Content-Type": "application/json",
"X-Client-Key": "629293dd-69cc-4904-a545-23deef377bd9",
},
withCredentials: true,
});
// Request interceptor
axiosInterceptorInstance.interceptors.request.use(
(config) => {
console.log("Config interceptor : ", config);
const accessToken = Cookies.get("access_token");
if (accessToken) {
if (config.headers)
config.headers.Authorization = "Bearer " + accessToken;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor
axiosInterceptorInstance.interceptors.response.use(
(response) => {
console.log("Response interceptor : ", response);
return response;
},
async function (error) {
console.log("Error interceptor : ", error.response.status);
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const data = {
grantType: "refresh_token",
refreshToken: refreshToken,
clientId: "mediahub-app",
};
console.log("refresh token ", data);
const res = await postSignIn(data);
if (res?.error) {
Object.keys(Cookies.get()).forEach((cookieName) => {
Cookies.remove(cookieName);
});
} else {
const { access_token } = res?.data;
const { refresh_token } = res?.data;
if (access_token) {
Cookies.set("access_token", access_token);
Cookies.set("refresh_token", refresh_token);
}
}
return axiosInterceptorInstance(originalRequest);
}
return Promise.reject(error);
},
);
export default axiosInterceptorInstance;

View File

@ -0,0 +1,12 @@
import axios from "axios";
const baseURL = "https://disestages.com/api";
const axiosDisestagesInstance = axios.create({
baseURL,
headers: {
"content-type": "application/json",
},
});
export default axiosDisestagesInstance;

View File

@ -0,0 +1,78 @@
import axiosDisestagesInstance from "./disestages-instance";
import axios from "axios";
const baseURL = "https://staging.disestages.com/api";
export async function httpPost(pathUrl: any, headers: any, data?: any) {
const response = await axiosDisestagesInstance
.post(pathUrl, data, { headers })
.catch(function (error) {
console.log(error);
return error.response;
});
console.log("Response base svc : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpGet(pathUrl: any, headers: any) {
const response = await axiosDisestagesInstance
.get(pathUrl, { headers })
.catch(function (error) {
console.log(error);
return error.response;
});
console.log("Response base svc : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpPost2(pathUrl: any, headers: any, data?: any) {
const response = await axios
.create({
baseURL,
headers: {
"content-type": "application/json",
},
})
.post(pathUrl, data, { headers })
.catch(function (error) {
console.log(error);
return error.response;
});
console.log("Response base svc : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}

View File

@ -0,0 +1,61 @@
import axiosBaseInstance from "./axios-base-instance";
const defaultHeaders = {
"Content-Type": "application/json",
"X-Client-Key": "629293dd-69cc-4904-a545-23deef377bd9",
};
export async function httpGet(pathUrl: any, headers?: any) {
console.log("X-HEADERS : ", defaultHeaders);
const mergedHeaders = {
...defaultHeaders,
...headers,
};
console.log("Merged Headers : ", mergedHeaders);
const response = await axiosBaseInstance
.get(pathUrl, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response base svc : ", response);
if (response?.data.success) {
return {
error: false,
message: "success",
data: response?.data,
};
} else {
return {
error: true,
message: response?.data?.message || null,
data: null,
};
}
}
export async function httpPost(pathUrl: any, data: any, headers?: any) {
const mergedHeaders = {
...defaultHeaders,
...headers,
};
const response = await axiosBaseInstance
.post(pathUrl, data, { headers: mergedHeaders })
.catch(function (error) {
console.log(error);
return error.response;
});
console.log("Response base svc : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}

View File

@ -0,0 +1,139 @@
import { useRouter } from "next/navigation";
import Cookies from "js-cookie";
import axiosInterceptorInstance from "./axios-interceptor-instance";
import { getCsrfToken } from "../master-user";
const defaultHeaders = {
"Content-Type": "application/json",
"X-Client-Key": "629293dd-69cc-4904-a545-23deef377bd9",
};
export async function httpGetInterceptor(pathUrl: any) {
console.log("X-HEADERS : ", defaultHeaders);
const response = await axiosInterceptorInstance
.get(pathUrl, { headers: defaultHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
return {
error: true,
};
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpPostInterceptor(
pathUrl: any,
data: any,
headers?: any,
) {
const resCsrf = await getCsrfToken();
const csrfToken = resCsrf?.data?.csrf_token;
const mergedHeaders = {
...defaultHeaders,
...headers,
...(csrfToken ? { "X-CSRF-TOKEN": csrfToken } : {}),
};
const response = await axiosInterceptorInstance
.post(pathUrl, data, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpPutInterceptor(
pathUrl: any,
data: any,
headers?: any,
) {
const resCsrf = await getCsrfToken();
const csrfToken = resCsrf?.data?.csrf_token;
const mergedHeaders = {
...defaultHeaders,
...headers,
...(csrfToken ? { "X-CSRF-TOKEN": csrfToken } : {}),
};
const response = await axiosInterceptorInstance
.put(pathUrl, data, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpDeleteInterceptor(pathUrl: any, headers?: any) {
const resCsrf = await getCsrfToken();
const csrfToken = resCsrf?.data?.csrf_token;
const mergedHeaders = {
...defaultHeaders,
...headers,
...(csrfToken ? { "X-CSRF-TOKEN": csrfToken } : {}),
};
const response = await axiosInterceptorInstance
.delete(pathUrl, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}

50
service/magazine.tsx Normal file
View File

@ -0,0 +1,50 @@
import { PaginationRequest } from "@/types/globals";
import Cookies from "js-cookie";
import { httpDeleteInterceptor, httpGetInterceptor, httpPostInterceptor, httpPutInterceptor } from "./http-config/http-interceptor-services";
const token = Cookies.get("access_token");
export async function createMagazine(data: any) {
const pathUrl = `/magazines`;
return await httpPostInterceptor(pathUrl, data);
}
export async function getListMagazine(props: PaginationRequest) {
const { page, limit, search, startDate, endDate } = props;
return await httpGetInterceptor(
`/magazines?limit=${limit}&page=${page}&title=${search}&startDate=${
startDate || ""
}&endDate=${endDate || ""}`
);
}
export async function updateMagazine(id: string, data: any) {
const pathUrl = `/magazines/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function getMagazineById(id: string) {
return await httpGetInterceptor(`/magazines/${id}`);
}
export async function deleteMagazine(id: string) {
return await httpDeleteInterceptor(`magazines/${id}`);
}
export async function uploadMagazineFile(id: string, data: any) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpPostInterceptor(`/magazine-files/${id}`, data, headers);
}
export async function uploadMagazineThumbnail(id: string, data: any) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpPostInterceptor(`/magazines/thumbnail/${id}`, data, headers);
}
export async function deleteMagazineFiles(id: number) {
return await httpDeleteInterceptor(`magazine-files/${id}`);
}

View File

@ -0,0 +1,29 @@
import Cookies from "js-cookie";
import { httpDeleteInterceptor, httpGetInterceptor, httpPostInterceptor, httpPutInterceptor } from "./http-config/http-interceptor-services";
const token = Cookies.get("access_token");
export async function createCategory(data: any) {
const pathUrl = `/article-categories`;
return await httpPostInterceptor(pathUrl, data);
}
export async function updateCategory(id: string, data: any) {
const pathUrl = `/article-categories/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function getCategoryById(id: number) {
return await httpGetInterceptor(`/article-categories/${id}`);
}
export async function deleteCategory(id: number) {
return await httpDeleteInterceptor(`article-categories/${id}`);
}
export async function uploadCategoryThumbnail(id: string, data: any) {
const headers = {
"content-type": "multipart/form-data",
};
return await httpPostInterceptor(`/article-categories/thumbnail/${id}`, data, headers);
}

View File

@ -0,0 +1,29 @@
import Cookies from "js-cookie";
import { httpDeleteInterceptor, httpGetInterceptor, httpPostInterceptor } from "./http-config/http-interceptor-services";
const token = Cookies.get("access_token");
export async function listUserRole(data: any) {
return await httpGetInterceptor(
`/user-roles?limit=${data.limit}&page=${data.page}`
);
}
export async function createMasterUserRole(data: any) {
const pathUrl = `/user-roles`;
return await httpPostInterceptor(pathUrl, data);
}
export async function getMasterUserRoleById(id: any) {
const headers = {
"content-type": "application/json",
};
return await httpGetInterceptor(`/user-roles/${id}`);
}
export async function deleteMasterUserRole(id: string) {
const headers = {
"content-type": "application/json",
};
return await httpDeleteInterceptor(`/user-roles/${id}`);
}

135
service/master-user.ts Normal file
View File

@ -0,0 +1,135 @@
import Cookies from "js-cookie";
import { httpGet, httpPost } from "./http-config/http-base-services";
import {
httpDeleteInterceptor,
httpGetInterceptor,
httpPostInterceptor,
httpPutInterceptor,
} from "./http-config/http-interceptor-services";
import { hex } from "framer-motion";
const token = Cookies.get("access_token");
const id = Cookies.get("uie");
export async function listMasterUsers(data: any) {
const headers = {
"content-type": "application/json",
};
return await httpGet(`/users?page=${data.page}&limit=${data.limit}`, headers);
}
export async function createMasterUser(data: any) {
const pathUrl = `/users`;
return await httpPostInterceptor(pathUrl, data);
}
export async function emailValidation(data: any) {
const pathUrl = `/users/email-validation`;
return await httpPost(pathUrl, data);
}
export async function setupEmail(data: any) {
const pathUrl = `/users/setup-email`;
return await httpPost(pathUrl, data);
}
export async function getDetailMasterUsers(id: string) {
const pathUrl = `/users/detail/${id}`;
return await httpGetInterceptor(pathUrl);
}
export async function editMasterUsers(data: any, id: string) {
const pathUrl = `/users/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function deleteMasterUser(id: string) {
const pathUrl = `/users/${id}`;
return await httpDeleteInterceptor(pathUrl);
}
export async function postSignIn(data: any) {
const pathUrl = `/users/login`;
return await httpPost(pathUrl, data);
}
export async function getProfile(token?: string) {
const headers = {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
};
const pathUrl = `/users/info`;
return await httpGet(pathUrl, headers);
}
export async function updateProfile(data: any) {
const pathUrl = `/users/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function savePassword(data: any) {
const pathUrl = `/users/save-password`;
return await httpPostInterceptor(pathUrl, data);
}
export async function resetPassword(data: any) {
const headers = {
"content-type": "application/json",
};
return await httpPost(`/users/reset-password`, headers, data);
}
export async function checkUsernames(username: string) {
const headers = {
"content-type": "application/json",
};
return await httpPost(`/users/forgot-password`, headers, { username });
}
export async function otpRequest(email: string, name: string) {
const pathUrl = `/users/otp-request`;
return await httpPost(pathUrl, { email, name });
}
export async function otpValidation(email: string, otpCode: string) {
const pathUrl = `/users/otp-validation`;
return await httpPost(pathUrl, { email, otpCode });
}
// export async function postArticleComment(data: any) {
// const headers = token
// ? {
// "content-type": "application/json",
// Authorization: `${token}`,
// }
// : {
// "content-type": "application/json",
// };
// return await httpPost(`/article-comments`, headers, data);
// }
export async function postArticleComment(data: any) {
const pathUrl = `/article-comments`;
return await httpPostInterceptor(pathUrl, data);
}
export async function editArticleComment(data: any, id: number) {
const pathUrl = `/article-comments/${id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function getArticleComment(id: string) {
const pathUrl = `/article-comments?isPublic=false&articleId=${id}`;
return await httpGet(pathUrl);
}
export async function deleteArticleComment(id: number) {
const pathUrl = `/article-comments/${id}`;
return await httpDeleteInterceptor(pathUrl);
}
export async function getCsrfToken() {
const pathUrl = "csrf-token";
const headers = {
"content-type": "application/json",
};
return httpGet(pathUrl, headers);
}

View File

@ -0,0 +1,28 @@
import { PaginationRequest } from "@/types/globals";
import { httpGetInterceptor, httpPostInterceptor, httpPutInterceptor } from "./http-config/http-interceptor-services";
import { httpGet } from "./http-config/http-base-services";
export async function createCustomStaticPage(data: any) {
const pathUrl = `/custom-static-pages`;
return await httpPostInterceptor(pathUrl, data);
}
export async function editCustomStaticPage(data: any) {
const pathUrl = `/custom-static-pages/${data.id}`;
return await httpPutInterceptor(pathUrl, data);
}
export async function getCustomStaticPage(props: PaginationRequest) {
const { page, limit, search } = props;
const pathUrl = `/custom-static-pages?limit=${limit}&page=${page}&title=${search}`;
return await httpGetInterceptor(pathUrl);
}
export async function getCustomStaticDetail(id: string) {
return await httpGetInterceptor(`/custom-static-pages/${id}`);
}
export async function getCustomStaticDetailBySlug(slug: string) {
const pathUrl = `/custom-static-pages/slug/${slug}`;
return await httpGet(pathUrl);
}

View File

@ -0,0 +1,30 @@
import Cookies from "js-cookie";
import { httpGetInterceptor, httpPostInterceptor, httpPutInterceptor } from "./http-config/http-interceptor-services";
const token = Cookies.get("access_token");
export async function getAllUserLevels(data?: any) {
const pathUrl = `user-levels?limit=${data?.limit || ""}&levelNumber=${
data?.levelNumber || ""
}&name=${data?.search || ""}&page=${data?.page || "1"}`
return await httpGetInterceptor(pathUrl);
}
export async function getUserLevels(id: string) {
return await httpGetInterceptor(`user-levels/${id}`);
}
export async function getAccountById(id: string) {
return await httpGetInterceptor(`user-account/findById/${id}`);
}
export async function createUserLevels(data: any) {
return await httpPostInterceptor(`user-levels`, data);
}
export async function editUserLevels(id: string, data: any) {
return await httpPutInterceptor(`user-levels/${id}`, data);
}
export async function changeIsApproval(data: any) {
return await httpPutInterceptor(`user-levels/enable-approval`, data);
}

View File

@ -0,0 +1,87 @@
// Global chunk loading error handler
export function setupChunkErrorHandler() {
if (typeof window === 'undefined') return;
// Handle chunk loading errors
window.addEventListener('error', (event) => {
const error = event.error;
// Check if it's a chunk loading error
if (
error?.name === 'ChunkLoadError' ||
error?.message?.includes('Loading chunk') ||
error?.message?.includes('Failed to fetch')
) {
console.warn('Chunk loading error detected:', error);
// Prevent the error from being logged to console
event.preventDefault();
// Show a user-friendly message
const message = document.createElement('div');
message.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
max-width: 300px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
`;
message.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span></span>
<span>Application update detected. Please refresh the page.</span>
</div>
<button onclick="window.location.reload()" style="
margin-top: 8px;
background: #dc2626;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">Refresh</button>
`;
document.body.appendChild(message);
// Auto-remove after 10 seconds
setTimeout(() => {
if (message.parentNode) {
message.parentNode.removeChild(message);
}
}, 10000);
}
});
// Handle unhandled promise rejections (which might include chunk loading errors)
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
if (
error?.name === 'ChunkLoadError' ||
error?.message?.includes('Loading chunk') ||
error?.message?.includes('Failed to fetch')
) {
console.warn('Unhandled chunk loading rejection:', error);
event.preventDefault();
// Reload the page after a short delay
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
}
// Auto-setup when this module is imported
if (typeof window !== 'undefined') {
setupChunkErrorHandler();
}

70
utils/dynamic-import.ts Normal file
View File

@ -0,0 +1,70 @@
import dynamic from 'next/dynamic';
import { ComponentType } from 'react';
interface DynamicImportOptions {
ssr?: boolean;
loading?: () => React.ReactElement;
retries?: number;
retryDelay?: number;
}
export function createSafeDynamicImport<T extends ComponentType<any>>(
importFn: () => Promise<{ default: T }>,
options: DynamicImportOptions = {}
) {
const { ssr = false, loading, retries = 3, retryDelay = 1000 } = options;
return dynamic(
() => {
return new Promise<T>((resolve, reject) => {
let attempts = 0;
const attemptImport = async () => {
try {
const module = await importFn();
resolve(module.default);
} catch (error) {
attempts++;
// Check if it's a chunk loading error
if (
(error as any)?.name === 'ChunkLoadError' ||
(error as any)?.message?.includes('Loading chunk') ||
(error as any)?.message?.includes('Failed to fetch')
) {
if (attempts < retries) {
console.warn(`Chunk loading failed, retrying... (${attempts}/${retries})`);
setTimeout(attemptImport, retryDelay);
return;
}
}
reject(error);
}
};
attemptImport();
});
},
{
ssr,
loading,
}
);
}
// Predefined safe dynamic imports for common components
export const SafeCustomEditor = createSafeDynamicImport(
() => import('@/components/editor/custom-editor'),
{ ssr: false }
);
export const SafeViewEditor = createSafeDynamicImport(
() => import('@/components/editor/view-editor'),
{ ssr: false }
);
export const SafeReactApexChart = createSafeDynamicImport(
() => import('react-apexcharts'),
{ ssr: false }
);

174
utils/global.tsx Normal file
View File

@ -0,0 +1,174 @@
"use client";
import { useEffect } from "react";
export function convertDateFormat(dateString: string) {
const date = new Date(dateString);
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes();
const formattedTime =
(hours < 10 ? "0" : "") + hours + ":" + (minutes < 10 ? "0" : "") + minutes;
const formattedDate =
(day < 10 ? "0" : "") +
day +
"-" +
(month < 10 ? "0" : "") +
month +
"-" +
year +
", " +
formattedTime;
return formattedDate;
}
// export function convertDateFormatNoTime(dateString: any) {
// var date = new Date(dateString);
// var day = date.getDate();
// var month = date.getMonth() + 1;
// var year = date.getFullYear();
// var formattedDate =
// (day < 10 ? "0" : "") +
// day +
// "-" +
// (month < 10 ? "0" : "") +
// month +
// "-" +
// year;
// return formattedDate;
// }
export function convertDateFormatNoTimeV2(dateString: string | Date) {
const date = new Date(dateString);
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
const formattedDate =
year +
"-" +
(month < 10 ? "0" : "") +
month +
"-" +
(day < 10 ? "0" : "") +
day;
return formattedDate;
}
export function formatTextToHtmlTag(text: string) {
if (text) {
const htmlText = text.replaceAll("\\n", "<br>").replaceAll(/"/g, "");
return { __html: htmlText };
}
}
const LoadScript = () => {
useEffect(() => {
const script = document.createElement("script");
script.src = "https://cdn.userway.org/widget.js";
script.setAttribute("data-account", "X36s1DpjqB");
script.async = true;
document.head.appendChild(script);
return () => {
// Cleanup if needed
document.head.removeChild(script);
};
}, []);
return null; // Tidak perlu merender apa-apa
};
export default LoadScript;
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function textEllipsis(
str: string,
maxLength: number,
{ side = "end", ellipsis = "..." } = {},
) {
if (str !== undefined && str?.length > maxLength) {
switch (side) {
case "start":
return ellipsis + str.slice(-(maxLength - ellipsis.length));
case "end":
default:
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}
}
return str;
}
export function htmlToString(str: string) {
if (str == undefined || str == null) {
return "";
}
return (
str
.replaceAll(/<style[^>]*>.*<\/style>/gm, "")
// Remove script tags and content
.replaceAll(/<script[^>]*>.*<\/script>/gm, "")
// Replace &nbsp,&ndash
.replaceAll("&nbsp;", "")
.replaceAll("&ndash;", "-")
// Replace quotation mark
.replaceAll("&ldquo;", '"')
.replaceAll("&rdquo;", '"')
// Remove all opening, closing and orphan HTML tags
.replaceAll(/<[^>]+>/gm, "")
// Remove leading spaces and repeated CR/LF
.replaceAll(/([\n\r]+ +)+/gm, "")
);
}
export function formatMonthString(dateString: string) {
const months = [
"Januari",
"Februari",
"Maret",
"April",
"Mei",
"Juni",
"Juli",
"Agustus",
"September",
"Oktober",
"November",
"Desember",
];
const date = new Date(dateString); // Konversi string ke objek Date
const day = date.getDate(); // Ambil tanggal
const month = months[date.getMonth()]; // Ambil nama bulan
const year = date.getFullYear(); // Ambil tahun
return `${day} ${month} ${year}`;
}
export function convertDateFormatNoTime(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDate(date: Date | null) {
if (!date) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}