siakad-itm/resources/js/components/rich-text-editor.tsx
Yoga Pangestu 62f8c167e8
Some checks failed
tests / ci (pull_request) Has been cancelled
feat: implement feedback system with CRUD functionality
- Add FeedbackController to handle feedback submissions and management.
- Create Feedback model and migration for feedbacks table.
- Introduce FeedbackStatus and FeedbackType enums for better type handling.
- Implement FeedbackService for business logic related to feedback.
- Create FeedbackRequest for validation of feedback data.
- Add Tiptap rich text editor for feedback message input.
- Develop frontend components for displaying and managing feedback.
- Add routes for feedback management in web.php.
- Create utility types for feedback in TypeScript.
- Update app-sidebar to include feedback navigation.
2026-08-25 14:25:50 +07:00

516 lines
17 KiB
TypeScript

import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import Underline from '@tiptap/extension-underline';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Loader2,
Redo2,
Underline as UnderlineIcon,
Undo2,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
type MediaItem = {
id: number;
name: string;
file_name: string;
mime_type: string;
size: number;
url: string;
};
type TiptapEditorProps = {
value?: string;
onChange?: (value: string) => void;
name?: string;
placeholder?: string;
modelType?: string;
modelId?: number | string;
collection?: string;
error?: string;
};
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
'hover:bg-muted hover:text-muted-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isActive && 'bg-accent text-accent-foreground',
)}
>
{children}
</button>
);
}
function ToolbarDivider() {
return <div className="h-6 w-px bg-border" />;
}
export default function TiptapEditor({
value = '',
onChange,
name = 'content',
placeholder = 'Tulis konten di sini...',
modelType = 'news',
modelId = 0,
collection = 'content',
error,
}: TiptapEditorProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [draftId, setDraftId] = useState<number | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Image.configure({
inline: false,
allowBase64: true,
}),
Placeholder.configure({
placeholder,
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline cursor-pointer',
},
}),
],
content: value,
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML());
},
editorProps: {
attributes: {
class: cn(
'prose prose-sm sm:prose-base max-w-none',
'min-h-[300px] w-full rounded-b-md px-3 py-2',
'focus:outline-none',
'[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold',
'[&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold',
'[&_p]:mb-2',
'[&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-6',
'[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6',
'[&_li]:mb-1',
'[&_img]:my-4 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md',
'[&_a]:text-primary [&_a]:underline',
'[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic',
'placeholder:text-muted-foreground',
),
},
},
});
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [value, editor]);
const getCsrfToken = () =>
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? '';
const handleImageUpload = useCallback(
async (file: File) => {
if (!editor) {
return;
}
setUploading(true);
try {
const token = getCsrfToken();
const headers = {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
Accept: 'application/json',
};
let currentModelId =
draftId ?? (modelId ? Number(modelId) : null);
if (!currentModelId) {
const initRes = await fetch(`/media/${modelType}`, {
method: 'POST',
headers,
});
if (!initRes.ok) {
throw new Error('Gagal membuat draft.');
}
const { id } = await initRes.json();
currentModelId = id;
setDraftId(id);
}
const presignedRes = await fetch(
`/media/${modelType}/${currentModelId}/presigned-url`,
{
method: 'POST',
headers,
body: JSON.stringify({
file_name: file.name,
mime_type: file.type,
size: file.size,
collection,
}),
},
);
if (!presignedRes.ok) {
throw new Error('Gagal mendapatkan URL unggahan.');
}
const {
id,
presigned_url,
headers: putHeaders,
} = await presignedRes.json();
await fetch(presigned_url, {
method: 'PUT',
headers: putHeaders,
body: file,
});
const completeRes = await fetch(`/media/item/${id}/complete`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': token,
Accept: 'application/json',
},
});
if (!completeRes.ok) {
throw new Error('Gagal menyelesaikan unggahan.');
}
const media: MediaItem = await completeRes.json();
editor
.chain()
.focus()
.setImage({ src: media.url, alt: file.name })
.run();
} catch (err) {
console.error('Image upload error:', err);
} finally {
setUploading(false);
}
},
[editor, modelType, modelId, collection, draftId],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[handleImageUpload],
);
const handleSetLink = useCallback(() => {
if (!editor) {
return;
}
if (linkUrl) {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: linkUrl })
.run();
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}
setShowLinkInput(false);
setLinkUrl('');
}, [editor, linkUrl]);
if (!editor) {
return null;
}
return (
<div className="grid gap-2">
<div className="rounded-md border border-input">
<div className="flex flex-wrap items-center gap-0.5 border-b border-border p-1">
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBold().run()
}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleUnderline().run()
}
isActive={editor.isActive('underline')}
title="Underline"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBulletList().run()
}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleOrderedList().run()
}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('left').run()
}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('center').run()
}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('right').run()
}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() => {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
setShowLinkInput(true);
}
}}
isActive={editor.isActive('link')}
title="Insert Link"
>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="Insert Image"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
</ToolbarButton>
<div className="ml-auto flex items-center gap-0.5">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
>
<Undo2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
>
<Redo2 className="h-4 w-4" />
</ToolbarButton>
</div>
</div>
{showLinkInput && (
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSetLink();
}
if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<button
type="button"
onClick={handleSetLink}
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
>
Set
</button>
<button
type="button"
onClick={() => {
setShowLinkInput(false);
setLinkUrl('');
}}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<EditorContent editor={editor} />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<input type="hidden" name={name} value={editor.getHTML()} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}