thumbnail update feature
This commit is contained in:
@@ -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)],
|
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
|
||||||
'description' => ['nullable', 'string', 'max:1000'],
|
'description' => ['nullable', 'string', 'max:1000'],
|
||||||
'category_id' => ['nullable', 'exists:categories,id'],
|
'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([
|
$channel->update([
|
||||||
...$validated,
|
...$validated,
|
||||||
'display_name' => trim($validated['display_name']),
|
'display_name' => trim($validated['display_name']),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ return [
|
|||||||
'local' => [
|
'local' => [
|
||||||
'driver' => 'local',
|
'driver' => 'local',
|
||||||
'root' => storage_path('app/private'),
|
'root' => storage_path('app/private'),
|
||||||
'serve' => true,
|
'serve' => false,
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Eye,
|
Eye,
|
||||||
|
ImagePlus,
|
||||||
Radio,
|
Radio,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Save,
|
Save,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { FormEvent, ReactNode } from 'react';
|
import type { FormEvent, ReactNode } from 'react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -73,6 +74,10 @@ export default function Dashboard({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [copiedText, copy] = useClipboard();
|
const [copiedText, copy] = useClipboard();
|
||||||
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
|
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 liveBroadcast = channel?.live_broadcast ?? null;
|
||||||
const pendingBroadcast =
|
const pendingBroadcast =
|
||||||
recentBroadcasts.find((broadcast) => broadcast.status === 'pending') ??
|
recentBroadcasts.find((broadcast) => broadcast.status === 'pending') ??
|
||||||
@@ -84,16 +89,26 @@ export default function Dashboard({
|
|||||||
category_id: '',
|
category_id: '',
|
||||||
});
|
});
|
||||||
const channelForm = useForm({
|
const channelForm = useForm({
|
||||||
|
_method: 'PATCH',
|
||||||
display_name: channel?.display_name ?? '',
|
display_name: channel?.display_name ?? '',
|
||||||
slug: channel?.slug ?? '',
|
slug: channel?.slug ?? '',
|
||||||
description: channel?.description ?? '',
|
description: channel?.description ?? '',
|
||||||
category_id: channel?.category_id ? String(channel.category_id) : '',
|
category_id: channel?.category_id ? String(channel.category_id) : '',
|
||||||
|
thumbnail: null as File | null,
|
||||||
});
|
});
|
||||||
const broadcastForm = useForm({
|
const broadcastForm = useForm({
|
||||||
title: '',
|
title: '',
|
||||||
recording_enabled: false,
|
recording_enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (thumbnailPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(thumbnailPreviewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [thumbnailPreviewUrl]);
|
||||||
|
|
||||||
function createChannel(event: FormEvent) {
|
function createChannel(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
createForm.submit(storeChannelRoute());
|
createForm.submit(storeChannelRoute());
|
||||||
@@ -101,7 +116,22 @@ export default function Dashboard({
|
|||||||
|
|
||||||
function updateChannel(event: FormEvent) {
|
function updateChannel(event: FormEvent) {
|
||||||
event.preventDefault();
|
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) {
|
function createBroadcast(event: FormEvent) {
|
||||||
@@ -390,6 +420,73 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<Field
|
||||||
label="Description"
|
label="Description"
|
||||||
error={channelForm.errors.description}
|
error={channelForm.errors.description}
|
||||||
|
|||||||
2
resume
2
resume
@@ -1 +1 @@
|
|||||||
codex resume 019e28d9-26f3-7ff3-b0f7-d0d833e46d7d
|
codex resume 019e2d2a-4c81-7983-958b-95e5f72e81a2
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
codex resume 019e2c64-42f1-7c71-9e37-c12e55862adf
|
|
||||||
@@ -6,6 +6,8 @@ use App\Models\Broadcast;
|
|||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -68,4 +70,60 @@ class DashboardTest extends TestCase
|
|||||||
->where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
|
->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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user