diff --git a/app/Http/Controllers/MediaUploadController.php b/app/Http/Controllers/MediaUploadController.php new file mode 100644 index 0000000..ebf4fa1 --- /dev/null +++ b/app/Http/Controllers/MediaUploadController.php @@ -0,0 +1,32 @@ +service->store( + $request->user(), + $request->file('file'), + $request->validated('collection') ?? 'content', + ); + + return response()->json([ + 'id' => $media->id, + 'name' => $media->name, + 'file_name' => $media->file_name, + 'mime_type' => $media->mime_type, + 'size' => $media->size, + 'url' => $media->getUrl(), + ]); + } +} diff --git a/app/Http/Requests/MediaUploadRequest.php b/app/Http/Requests/MediaUploadRequest.php new file mode 100644 index 0000000..07a91ce --- /dev/null +++ b/app/Http/Requests/MediaUploadRequest.php @@ -0,0 +1,21 @@ + ['required', 'image', 'max:10240', 'mimes:jpg,jpeg,png,gif,webp'], + 'collection' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/app/Models/EditorUpload.php b/app/Models/EditorUpload.php new file mode 100644 index 0000000..10c50eb --- /dev/null +++ b/app/Models/EditorUpload.php @@ -0,0 +1,20 @@ +belongsTo(User::class, 'uploaded_by'); + } +} diff --git a/app/Services/Admin/FeedbackService.php b/app/Services/Admin/FeedbackService.php index 1a9de84..a5e4215 100644 --- a/app/Services/Admin/FeedbackService.php +++ b/app/Services/Admin/FeedbackService.php @@ -35,7 +35,7 @@ public function __construct() ->allowElement('a', ['href']) ->allowElement('img', ['src', 'alt']) ->allowLinkSchemes(['http', 'https', 'mailto']) - ->allowMediaSchemes(['https']) + ->allowMediaSchemes(['http', 'https']) ->withAttributeSanitizer(new TextAlignAttributeSanitizer); $this->sanitizer = new HtmlSanitizer($config); diff --git a/app/Services/MediaUploadService.php b/app/Services/MediaUploadService.php new file mode 100644 index 0000000..518713e --- /dev/null +++ b/app/Services/MediaUploadService.php @@ -0,0 +1,18 @@ + $user->id]); + + return $upload->addMedia($file)->toMediaCollection($collection); + } +} diff --git a/database/migrations/2026_08_31_160000_create_editor_uploads_table.php b/database/migrations/2026_08_31_160000_create_editor_uploads_table.php new file mode 100644 index 0000000..312b962 --- /dev/null +++ b/database/migrations/2026_08_31_160000_create_editor_uploads_table.php @@ -0,0 +1,22 @@ +id(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('editor_uploads'); + } +}; diff --git a/resources/js/components/rich-text-editor.tsx b/resources/js/components/rich-text-editor.tsx index 5ea7580..4c5d0d5 100644 --- a/resources/js/components/rich-text-editor.tsx +++ b/resources/js/components/rich-text-editor.tsx @@ -24,7 +24,10 @@ import { X, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { richTextContentClass } from '@/lib/rich-text-content'; import { cn } from '@/lib/utils'; +import { store as uploadMedia } from '@/routes/media/uploads'; type MediaItem = { id: number; @@ -40,8 +43,6 @@ type TiptapEditorProps = { onChange?: (value: string) => void; name?: string; placeholder?: string; - modelType?: string; - modelId?: number | string; collection?: string; error?: string; }; @@ -86,8 +87,6 @@ export default function TiptapEditor({ onChange, name = 'content', placeholder = 'Tulis konten di sini...', - modelType = 'news', - modelId = 0, collection = 'content', error, }: TiptapEditorProps) { @@ -95,7 +94,6 @@ export default function TiptapEditor({ const [uploading, setUploading] = useState(false); const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(''); - const [draftId, setDraftId] = useState(null); const editor = useEditor({ extensions: [ @@ -132,15 +130,7 @@ export default function TiptapEditor({ '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', + richTextContentClass, 'placeholder:text-muted-foreground', ), }, @@ -167,74 +157,30 @@ export default function TiptapEditor({ setUploading(true); try { - const token = getCsrfToken(); - const headers = { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': token, - Accept: 'application/json', - }; + const formData = new FormData(); + formData.append('file', file); + formData.append('collection', collection); - 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`, { + const res = await fetch(uploadMedia.url(), { method: 'POST', headers: { - 'X-CSRF-TOKEN': token, + 'X-CSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, + body: formData, }); - if (!completeRes.ok) { - throw new Error('Gagal menyelesaikan unggahan.'); + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = + body?.errors?.file?.[0] ?? + body?.message ?? + 'Gagal mengunggah gambar.'; + + throw new Error(message); } - const media: MediaItem = await completeRes.json(); + const media: MediaItem = await res.json(); editor .chain() @@ -242,12 +188,18 @@ export default function TiptapEditor({ .setImage({ src: media.url, alt: file.name }) .run(); } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Gagal mengunggah gambar.'; + + toast.error(message); console.error('Image upload error:', err); } finally { setUploading(false); } }, - [editor, modelType, modelId, collection, draftId], + [editor, collection], ); const handleFileSelect = useCallback( diff --git a/resources/js/lib/rich-text-content.ts b/resources/js/lib/rich-text-content.ts new file mode 100644 index 0000000..914c755 --- /dev/null +++ b/resources/js/lib/rich-text-content.ts @@ -0,0 +1,11 @@ +export const richTextContentClass = [ + '[&_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 [&_p:last-child]:mb-0', + '[&_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', +].join(' '); diff --git a/resources/js/pages/admin/feedback/index.tsx b/resources/js/pages/admin/feedback/index.tsx index b01e61d..8afb087 100644 --- a/resources/js/pages/admin/feedback/index.tsx +++ b/resources/js/pages/admin/feedback/index.tsx @@ -30,6 +30,8 @@ import { } from '@/components/ui/select'; import { usePermissions } from '@/hooks/use-permissions'; import { useServerTable } from '@/hooks/use-server-table'; +import { richTextContentClass } from '@/lib/rich-text-content'; +import { cn } from '@/lib/utils'; import { index as feedbackIndex, destroy, @@ -413,7 +415,10 @@ function ViewDetailDialog({ Pesan
name('read'); Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy'); }); + + Route::prefix('media')->name('media.')->group(function () { + Route::post('uploads', [MediaUploadController::class, 'store'])->name('uploads.store'); + }); }); Route::get('.well-known/passkey-endpoints', function () {