Files
hstream/resources/views/livewire/admin-episode-form.blade.php
w33b d08e280ce0 refactor(episode): migrate episode upload to Livewire with real-time CDN verification
Replace the server-rendered episode upload form with a Livewire component,
introducing an interactive admin experience that validates the new episode's
stream path against all configured mirrors before saving. The controller-based
`store` method and its route are removed, and episode creation now flows
through the `AdminEpisodeForm` component with frontend upload handling and
dynamic quality checks.

Key changes:
- Add `AdminEpisodeForm` Livewire component with file uploads, tag handling,
  and debounced stream/base-url validation.
- Extend `CdnPathValidator::validateStream` to accept an episode number so the
  manifest path can point to the specific episode being added.
- Remove the `store` method from `EpisodeController` and its POST route.
- Delete the legacy `createEpisode` method from `EpisodeService`.
- Replace the Blade upload modal with the Livewire view, including progress
  indicators and validation badges.
- Add feature tests for the Livewire component and update unit tests for the
  CDN validator's episode-specific manifest check.
2026-08-08 14:29:05 +02:00

186 lines
12 KiB
PHP

<form wire:submit="save">
{{-- Info card --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase mb-3">Adding Episode {{ $episodeNumber }} to {{ $title }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Studio</label>
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center">{{ $studio }}</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Release Date</label>
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center">{{ $releasedate }}</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Title JPN</label>
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center truncate">{{ $titleJpn }}</div>
</div>
</div>
<div class="mt-3">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Tags</label>
<div class="w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 py-2">{{ implode(', ', $tags) }}</div>
</div>
</div>
{{-- Stream card --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase mb-3">Stream</h2>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="baseurl">Base URL (e.g. 2026/Title) *</label>
<div class="flex flex-wrap items-center gap-3">
<input wire:model.live.debounce.800ms="baseurl" id="baseurl" type="text" autocomplete="off"
placeholder="2026/Title"
class="flex-1 min-w-[200px] h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3">
@include('livewire.partials.validation-badge', ['status' => $validation['stream'] ?? null])
</div>
@error('baseurl') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
</div>
{{-- Files card --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4"
x-data="{
coverProgress: null,
galleryProgress: null,
uploadingCover: false,
uploadingGallery: false,
onUploadStart(e) {
if (e.detail.name === 'cover') { this.uploadingCover = true; this.coverProgress = 0; }
if (e.detail.name === 'gallery') { this.uploadingGallery = true; this.galleryProgress = 0; }
},
onUploadProgress(e) {
if (e.detail.name === 'cover') { this.coverProgress = e.detail.progress; }
if (e.detail.name === 'gallery') { this.galleryProgress = e.detail.progress; }
},
onUploadFinish(e) {
if (e.detail.name === 'cover') { this.uploadingCover = false; }
if (e.detail.name === 'gallery') { this.uploadingGallery = false; }
},
onUploadError(e) {
if (e.detail.name === 'cover') { this.uploadingCover = false; }
if (e.detail.name === 'gallery') { this.uploadingGallery = false; }
}
}"
x-on:livewire-upload-start.window="onUploadStart"
x-on:livewire-upload-finish.window="onUploadFinish"
x-on:livewire-upload-error.window="onUploadError"
x-on:livewire-upload-progress.window="onUploadProgress">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Cover --}}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="cover">Cover *</label>
<div class="flex items-start gap-3">
<div class="flex-1">
<input wire:model="cover" id="cover" type="file" accept="image/*" required
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white file:mr-3 file:rounded-md file:border-0 file:bg-rose-600 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-rose-700">
@error('cover') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
<div x-show="uploadingCover" class="mt-2" x-cloak>
<div class="h-1.5 w-full bg-gray-200 dark:bg-neutral-700 rounded-full overflow-hidden">
<div class="h-full bg-rose-600 transition-all duration-150" :style="'width:' + (coverProgress || 0) + '%'"></div>
</div>
<span class="text-[10px] text-gray-500 dark:text-gray-400" x-text="Math.round(coverProgress || 0) + '%'"></span>
</div>
</div>
@if ($cover)
<img src="{{ $cover->temporaryUrl() }}" alt="Cover preview"
class="w-24 h-36 object-cover rounded-lg border border-gray-200 dark:border-neutral-600 flex-shrink-0">
@endif
</div>
</div>
{{-- Gallery --}}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="gallery">Gallery (multiple)</label>
<input wire:model="gallery" id="gallery" type="file" accept="image/*" multiple
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white file:mr-3 file:rounded-md file:border-0 file:bg-rose-600 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-rose-700">
@error('gallery') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
<div x-show="uploadingGallery" class="mt-2" x-cloak>
<div class="h-1.5 w-full bg-gray-200 dark:bg-neutral-700 rounded-full overflow-hidden">
<div class="h-full bg-rose-600 transition-all duration-150" :style="'width:' + (galleryProgress || 0) + '%'"></div>
</div>
<span class="text-[10px] text-gray-500 dark:text-gray-400" x-text="Math.round(galleryProgress || 0) + '%'"></span>
</div>
@if (! empty($gallery))
<div class="mt-2 grid grid-cols-3 gap-2">
@foreach ($gallery as $galleryImage)
<img wire:key="gallery-{{ $loop->index }}" src="{{ $galleryImage->temporaryUrl() }}"
alt="Gallery preview" class="w-full h-16 object-cover rounded-md border border-gray-200 dark:border-neutral-600">
@endforeach
</div>
@endif
</div>
</div>
<div class="mt-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="description">Description *</label>
<textarea wire:model="description" id="description" rows="3"
class="mt-1 block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3 py-2"></textarea>
@error('description') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
@php
$downloadFields = [
'fhd' => 'Download 1080p *',
'fhdi' => 'Download 1080p 48fps',
'uhd' => 'Download 4k *',
'uhdi' => 'Download 4k 48fps',
];
@endphp
@foreach ($downloadFields as $quality => $label)
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="dl-{{ $quality }}">{{ $label }}</label>
<div class="flex flex-wrap items-center gap-3">
<input wire:model.live.debounce.800ms="downloads.{{ $quality }}" id="dl-{{ $quality }}" type="text" autocomplete="off"
placeholder="2026/Title/E0{{ $episodeNumber }}.mkv"
class="flex-1 min-w-[180px] h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3">
@include('livewire.partials.validation-badge', ['status' => $validation['downloads.' . $quality] ?? null])
</div>
@error("downloads.{$quality}") <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
@endforeach
</div>
</div>
{{-- Action bar --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 flex flex-wrap items-center justify-end gap-4">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" wire:model="censored"
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
Censored Notification
</label>
@if ($this->hasInvalidValidation)
<label class="inline-flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 cursor-pointer">
<input type="checkbox" wire:model="overrideValidation"
class="w-4 h-4 text-amber-600 bg-gray-100 border-gray-300 rounded focus:ring-amber-500 dark:bg-gray-700 dark:border-gray-600">
Save anyway (skip CDN validation)
</label>
@endif
<button type="button" data-te-modal-dismiss
class="inline-block rounded bg-gray-200 dark:bg-neutral-700 px-6 py-2.5 text-xs font-medium uppercase leading-normal text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-neutral-600 transition">
Cancel
</button>
<button type="submit" wire:loading.attr="disabled" wire:target="save"
class="inline-flex items-center gap-2 rounded bg-rose-600 px-6 py-2.5 text-xs font-medium uppercase leading-normal text-white hover:bg-rose-700 disabled:opacity-60 transition">
<svg wire:loading wire:target="save" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span wire:loading.remove wire:target="save">Add</span>
<span wire:loading wire:target="save">Saving…</span>
</button>
</div>
</form>