Some checks failed
tests / ci (pull_request) Has been cancelled
- 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.
516 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|