Files
hstream/resources/views/livewire/admin-release-form.blade.php
w33b 30a6b080eb feat(admin): rebuild release upload form with Livewire and CDN validation
Replace the server-rendered release upload form with a Livewire component,
introducing an interactive admin release form that supports dynamic episode
management, real‑time CDN path validation, and direct download file checks.

Key changes:
- New `AdminReleaseForm` Livewire component with dynamic episode fields,
  Tagify integration, and upload handling via Livewire's file uploads.
- Added `CdnPathValidator` service to verify download and stream paths across
  all configured mirrors, caching results and capturing file sizes.
- Extended `EpisodeService` and `GalleryService` to support array‑based data
  and temporary uploaded files from Livewire.
- Persist validated download sizes and store the validation timestamp on the
  `downloads` table via a new migration; also add a unique constraint on
  `hentais.slug` to prevent duplicates.
- Remove the old POST `/admin/release/upload` route and controller logic,
  along with the legacy `upload.js` script.
- Add comprehensive feature and unit tests covering the new component and the
  CDN validation service.
2026-08-08 13:18:57 +02:00

235 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
<div class="flex justify-center">
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
{{-- Metadata 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">Release Metadata</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="title">Title *</label>
<input wire:model="title" id="title" type="text" autocomplete="off"
class="w-full 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">
@error('title') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="titleJpn">Title JPN *</label>
<input wire:model="titleJpn" id="titleJpn" type="text" autocomplete="off"
class="w-full 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">
@error('titleJpn') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="studio">Studio *</label>
<div wire:ignore x-data="adminReleaseStudio">
<input id="studio" x-ref="studio" type="text" autocomplete="off" placeholder="Select or type a studio"
class="w-full 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">
</div>
@error('studio') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="releasedate">Release Date *</label>
<input wire:model="releasedate" id="releasedate" type="date"
class="w-full 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">
@error('releasedate') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
</div>
<div class="mt-3">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="tags">Tags *</label>
<div wire:ignore x-data="adminReleaseTags">
<input id="tags" x-ref="tags" type="text" autocomplete="off" placeholder="Add tags"
class="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">
</div>
@error('tags') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
<div class="mt-3 flex items-center gap-3">
<div>
<span class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Episodes</span>
<div class="flex items-center gap-2">
<button type="button" wire:click="removeEpisode({{ count($episodes) - 1 }})"
class="w-9 h-9 rounded-lg border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-neutral-700 transition text-lg leading-none"></button>
<span class="w-10 text-center text-lg font-semibold">{{ $episodeCount }}</span>
<button type="button" wire:click="addEpisode"
class="w-9 h-9 rounded-lg border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-neutral-700 transition text-lg leading-none">+</button>
</div>
</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>
{{-- Episode cards --}}
@foreach ($episodes as $index => $episode)
<div wire:key="episode-card-{{ $index }}"
x-data="{
coverProgress: null,
galleryProgress: null,
uploadingCover: false,
uploadingGallery: false,
onUploadStart(e) {
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = true; this.coverProgress = 0; }
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.uploadingGallery = true; this.galleryProgress = 0; }
},
onUploadProgress(e) {
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.coverProgress = e.detail.progress; }
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.galleryProgress = e.detail.progress; }
},
onUploadFinish(e) {
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = false; }
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.uploadingGallery = false; }
},
onUploadError(e) {
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = false; }
if (e.detail.name === 'episodes.{{ $index }}.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"
class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase">Episode {{ $index + 1 }}</h2>
@if (count($episodes) > 1)
<button type="button" wire:click="removeEpisode({{ $index }})"
class="text-xs text-red-500 hover:text-red-600 dark:hover:text-red-400 font-medium transition">Remove episode</button>
@endif
</div>
<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="episode-cover-{{ $index }}">Cover *</label>
<div class="flex items-start gap-3">
<div class="flex-1">
<input wire:model="episodes.{{ $index }}.cover" id="episode-cover-{{ $index }}" 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("episodes.{$index}.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 ($episode['cover'])
<img src="{{ $episode['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="episode-gallery-{{ $index }}">Gallery (multiple)</label>
<input wire:model="episodes.{{ $index }}.gallery" id="episode-gallery-{{ $index }}" 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("episodes.{$index}.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($episode['gallery']))
<div class="mt-2 grid grid-cols-3 gap-2">
@foreach ($episode['gallery'] as $gallery)
<img wire:key="gallery-{{ $index }}-{{ $loop->index }}" src="{{ $gallery->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-{{ $index }}">Description *</label>
<textarea wire:model="episodes.{{ $index }}.description" id="description-{{ $index }}" 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("episodes.{$index}.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-{{ $index }}-{{ $quality }}">{{ $label }}</label>
<div class="flex flex-wrap items-center gap-3">
<input wire:model.live.debounce.800ms="episodes.{{ $index }}.downloads.{{ $quality }}" id="dl-{{ $index }}-{{ $quality }}" type="text" autocomplete="off"
placeholder="2026/Title/E0{{ $index + 1 }}.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['episodes.' . $index . '.downloads.' . $quality] ?? null])
</div>
@error("episodes.{$index}.downloads.{$quality}") <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
</div>
@endforeach
</div>
</div>
@endforeach
{{-- 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
<a href="{{ route('home.index') }}"
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
</a>
<button type="button" wire:click="save" 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">Create</span>
<span wire:loading wire:target="save">Saving…</span>
</button>
</div>
</div>
</div>
</div>