thumbnail update feature

This commit is contained in:
2026-05-16 00:31:34 +03:30
parent 63551f9661
commit 34445f3032
6 changed files with 170 additions and 5 deletions

View File

@@ -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']),

View File

@@ -33,7 +33,7 @@ return [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'serve' => false,
'throw' => false,
'report' => false,
],

View File

@@ -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<BroadcastSummary | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
string | null
>(null);
const thumbnailInputRef = useRef<HTMLInputElement>(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({
}
/>
</Field>
<Field
label="Live thumbnail"
error={channelForm.errors.thumbnail}
className="md:col-span-2"
>
<div className="flex flex-col gap-3 rounded-md border bg-background p-3 sm:flex-row sm:items-center">
<div className="aspect-video w-full overflow-hidden rounded-md bg-muted sm:w-40 sm:shrink-0">
{thumbnailPreviewUrl ||
channel.thumbnail_url ? (
<img
src={
thumbnailPreviewUrl ??
channel.thumbnail_url ??
''
}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full place-items-center text-xs text-muted-foreground">
No thumbnail
</div>
)}
</div>
<div className="grid min-w-0 flex-1 gap-2">
<Input
ref={thumbnailInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) =>
updateThumbnail(
event.target
.files?.[0] ??
null,
)
}
/>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ImagePlus className="size-3.5" />
<span>
Use a 16:9 JPG, PNG, or
WebP up to 4 MB.
</span>
{channelForm.data
.thumbnail && (
<span className="truncate font-medium text-foreground">
{
channelForm.data
.thumbnail
.name
}
</span>
)}
</div>
{channelForm.progress && (
<progress
value={
channelForm.progress
.percentage
}
max="100"
className="h-1.5 w-full"
/>
)}
</div>
</div>
</Field>
<Field
label="Description"
error={channelForm.errors.description}

2
resume
View File

@@ -1 +1 @@
codex resume 019e28d9-26f3-7ff3-b0f7-d0d833e46d7d
codex resume 019e2d2a-4c81-7983-958b-95e5f72e81a2

View File

@@ -1 +0,0 @@
codex resume 019e2c64-42f1-7c71-9e37-c12e55862adf

View File

@@ -6,6 +6,8 @@ use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
@@ -68,4 +70,60 @@ class DashboardTest extends TestCase
->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);
}
}