feat: implement media upload functionality with controller, request, service, and migration
This commit is contained in:
parent
daeeb3eb52
commit
b4ffdcb9dd
32
app/Http/Controllers/MediaUploadController.php
Normal file
32
app/Http/Controllers/MediaUploadController.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\MediaUploadRequest;
|
||||
use App\Services\MediaUploadService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MediaUploadController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MediaUploadService $service,
|
||||
) {}
|
||||
|
||||
public function store(MediaUploadRequest $request): JsonResponse
|
||||
{
|
||||
$media = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/MediaUploadRequest.php
Normal file
21
app/Http/Requests/MediaUploadRequest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class MediaUploadRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'image', 'max:10240', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
'collection' => ['nullable', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Models/EditorUpload.php
Normal file
20
app/Models/EditorUpload.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class EditorUpload extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
18
app/Services/MediaUploadService.php
Normal file
18
app/Services/MediaUploadService.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EditorUpload;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class MediaUploadService
|
||||
{
|
||||
public function store(User $user, UploadedFile $file, string $collection = 'content'): Media
|
||||
{
|
||||
$upload = EditorUpload::create(['uploaded_by' => $user->id]);
|
||||
|
||||
return $upload->addMedia($file)->toMediaCollection($collection);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('editor_uploads', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('editor_uploads');
|
||||
}
|
||||
};
|
||||
@ -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<number | null>(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(
|
||||
|
||||
11
resources/js/lib/rich-text-content.ts
Normal file
11
resources/js/lib/rich-text-content.ts
Normal file
@ -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(' ');
|
||||
@ -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
|
||||
</Label>
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 last:[&_p]:mb-0 [&_ul]:list-disc [&_ul]:pl-5"
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
richTextContentClass,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: feedback.message,
|
||||
}}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\MediaUploadController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -14,6 +15,10 @@
|
||||
Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user