From 34445f3032b9dc7db44996aac6c18d26beb08848 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Sat, 16 May 2026 00:31:34 +0330 Subject: [PATCH] thumbnail update feature --- .../CreatorDashboardController.php | 11 ++ config/filesystems.php | 2 +- resources/js/pages/dashboard.tsx | 101 +++++++++++++++++- resume | 2 +- resume-ui-plan | 1 - tests/Feature/DashboardTest.php | 58 ++++++++++ 6 files changed, 170 insertions(+), 5 deletions(-) delete mode 100644 resume-ui-plan diff --git a/app/Http/Controllers/CreatorDashboardController.php b/app/Http/Controllers/CreatorDashboardController.php index d20ad55..4423f1d 100644 --- a/app/Http/Controllers/CreatorDashboardController.php +++ b/app/Http/Controllers/CreatorDashboardController.php @@ -90,8 +90,19 @@ class CreatorDashboardController extends Controller 'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)], 'description' => ['nullable', 'string', 'max:1000'], 'category_id' => ['nullable', 'exists:categories,id'], + 'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], ]); + if ($request->hasFile('thumbnail')) { + if ($channel->thumbnail_path) { + Storage::disk('public')->delete($channel->thumbnail_path); + } + + $validated['thumbnail_path'] = $request->file('thumbnail')->store("channels/{$channel->id}/thumbnails", 'public'); + } + + unset($validated['thumbnail']); + $channel->update([ ...$validated, 'display_name' => trim($validated['display_name']), diff --git a/config/filesystems.php b/config/filesystems.php index dd0c9f7..278b5a2 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -33,7 +33,7 @@ return [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), - 'serve' => true, + 'serve' => false, 'throw' => false, 'report' => false, ], diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 5582f2f..4b8164b 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -3,6 +3,7 @@ import { Check, Copy, Eye, + ImagePlus, Radio, RotateCw, Save, @@ -11,7 +12,7 @@ import { Video, } from 'lucide-react'; import type { FormEvent, ReactNode } from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -73,6 +74,10 @@ export default function Dashboard({ }: Props) { const [copiedText, copy] = useClipboard(); const [stopTarget, setStopTarget] = useState(null); + const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState< + string | null + >(null); + const thumbnailInputRef = useRef(null); const liveBroadcast = channel?.live_broadcast ?? null; const pendingBroadcast = recentBroadcasts.find((broadcast) => broadcast.status === 'pending') ?? @@ -84,16 +89,26 @@ export default function Dashboard({ category_id: '', }); const channelForm = useForm({ + _method: 'PATCH', display_name: channel?.display_name ?? '', slug: channel?.slug ?? '', description: channel?.description ?? '', category_id: channel?.category_id ? String(channel.category_id) : '', + thumbnail: null as File | null, }); const broadcastForm = useForm({ title: '', recording_enabled: false, }); + useEffect(() => { + return () => { + if (thumbnailPreviewUrl) { + URL.revokeObjectURL(thumbnailPreviewUrl); + } + }; + }, [thumbnailPreviewUrl]); + function createChannel(event: FormEvent) { event.preventDefault(); createForm.submit(storeChannelRoute()); @@ -101,7 +116,22 @@ export default function Dashboard({ function updateChannel(event: FormEvent) { event.preventDefault(); - channelForm.submit(updateChannelRoute()); + channelForm.post(updateChannelRoute.url(), { + forceFormData: true, + onSuccess: () => { + channelForm.reset('thumbnail'); + setThumbnailPreviewUrl(null); + + if (thumbnailInputRef.current) { + thumbnailInputRef.current.value = ''; + } + }, + }); + } + + function updateThumbnail(file: File | null) { + channelForm.setData('thumbnail', file); + setThumbnailPreviewUrl(file ? URL.createObjectURL(file) : null); } function createBroadcast(event: FormEvent) { @@ -390,6 +420,73 @@ export default function Dashboard({ } /> + +
+
+ {thumbnailPreviewUrl || + channel.thumbnail_url ? ( + + ) : ( +
+ No thumbnail +
+ )} +
+
+ + updateThumbnail( + event.target + .files?.[0] ?? + null, + ) + } + /> +
+ + + Use a 16:9 JPG, PNG, or + WebP up to 4 MB. + + {channelForm.data + .thumbnail && ( + + { + channelForm.data + .thumbnail + .name + } + + )} +
+ {channelForm.progress && ( + + )} +
+
+
where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'), ); } + + public function test_creator_can_update_channel_thumbnail(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $channel = Channel::factory()->for($user)->create([ + 'thumbnail_path' => 'channels/demo/old-thumb.jpg', + ]); + + Storage::disk('public')->put($channel->thumbnail_path, 'old thumbnail'); + + $this->actingAs($user) + ->post(route('creator.channel.update'), [ + '_method' => 'PATCH', + 'display_name' => $channel->display_name, + 'slug' => $channel->slug, + 'description' => $channel->description, + 'category_id' => $channel->category_id, + 'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720), + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $channel->refresh(); + + $this->assertNotSame('channels/demo/old-thumb.jpg', $channel->thumbnail_path); + $this->assertStringStartsWith("channels/{$channel->id}/thumbnails/", $channel->thumbnail_path); + Storage::disk('public')->assertMissing('channels/demo/old-thumb.jpg'); + Storage::disk('public')->assertExists($channel->thumbnail_path); + } + + public function test_creator_channel_thumbnail_must_be_an_image(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $channel = Channel::factory()->for($user)->create([ + 'thumbnail_path' => 'channels/demo/current-thumb.jpg', + ]); + + $this->actingAs($user) + ->from(route('dashboard')) + ->post(route('creator.channel.update'), [ + '_method' => 'PATCH', + 'display_name' => $channel->display_name, + 'slug' => $channel->slug, + 'description' => $channel->description, + 'category_id' => $channel->category_id, + 'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'), + ]) + ->assertRedirect(route('dashboard')) + ->assertSessionHasErrors('thumbnail'); + + $this->assertSame('channels/demo/current-thumb.jpg', $channel->refresh()->thumbnail_path); + } }