Compare commits

...

9 Commits

Author SHA1 Message Date
w33b 60f6c639e5 chore(deps): add Laravel Boost development tooling and configuration
Add laravel/boost as a dev dependency, enable MCP server and skills via boost.json, and include AGENTS.md guidelines. Update .gitignore to exclude .env.testing, /.kilo, and /.cursor.
2026-08-09 13:12:41 +02:00
w33b df6246e3c9 feat(playlist): enhance public playlist index with hero stats and loading optimizations
Add a redesigned hero section to the public playlist page displaying
community-generated totals for playlists and episodes, plus new string
translations (English, German, French). Eager-load user, limited episode
previews, and first gallery image to reduce N+1 queries. Cache aggregate
statistics for 10 minutes to improve performance on high-traffic pages.
2026-08-09 10:36:03 +02:00
w33b 338c190ae8 feat(nav): redesign navigation bar and add Browse/Community links
Enhance the navigation with a modern floating glass design, new Browse and Community dropdown links, and improved theme/blur toggles that now handle multiple instances. Update dropdown components with rounded styling and support for wider widths, refine nav-link active states, and add navigation feature tests.
2026-08-09 10:13:54 +02:00
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
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
w33b 2076517ebd fix(discord): only send notifications in production
Prevent Discord release notifications from being sent in non-production
environments, avoiding accidental notifications during development and
testing. Added unit tests to verify the behavior.
2026-08-08 12:51:22 +02:00
w33b 71d679cc5e Update tests 2026-08-04 21:00:01 +02:00
w33b 9dcf13a62e Update playlist sidebar to use livewire 2026-08-04 18:33:50 +02:00
w33b 7f9573fe0f Update playlist design 2026-08-04 15:29:39 +02:00
86 changed files with 5356 additions and 1559 deletions
+3
View File
@@ -11,6 +11,7 @@
.env .env
.env.backup .env.backup
.env.production .env.production
.env.testing
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
@@ -21,3 +22,5 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
.directory .directory
/.kilo
/.cursor
+209
View File
@@ -0,0 +1,209 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/scout (SCOUT) - v10
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2
- laravel/breeze (BREEZE) - v2
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v3
## Skills Activation
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
## Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
## Searching Documentation (IMPORTANT)
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
### Search Syntax
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is perfectly fine and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
## Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
# Livewire
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
</laravel-boost-guidelines>
@@ -30,34 +30,6 @@ class EpisodeController extends Controller
$this->downloadService = $downloadService; $this->downloadService = $downloadService;
} }
/**
* Add Episode to existing series
*/
public function store(Request $request): RedirectResponse
{
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
// Create Episode
$episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode);
$this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1);
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($referenceEpisode->title.' - '.$episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episode->slug, 'release');
}
cache()->flush();
return to_route('hentai.index', [
'title' => $episode->slug,
]);
}
/** /**
* Edit Episode * Edit Episode
*/ */
@@ -3,33 +3,10 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Hentai;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class ReleaseController extends Controller class ReleaseController extends Controller
{ {
protected EpisodeService $episodeService;
protected GalleryService $galleryService;
protected DownloadService $downloadService;
public function __construct(
EpisodeService $episodeService,
GalleryService $galleryService,
DownloadService $downloadService
) {
$this->episodeService = $episodeService;
$this->galleryService = $galleryService;
$this->downloadService = $downloadService;
}
/** /**
* Display release page * Display release page
*/ */
@@ -37,55 +14,4 @@ class ReleaseController extends Controller
{ {
return view('admin.release.create'); return view('admin.release.create');
} }
/**
* Upload New Hentai with One or Multipe Episodes
*/
public function store(Request $request): RedirectResponse
{
// Create new Hentai or find existing one
$slug = $this->episodeService->generateSlug($request->input('title'));
$hentai = Hentai::where('slug', $slug)->first();
// If hentai exists and was created today, return to home
if ($hentai?->created_at->isToday()) {
return to_route('home.index');
}
// If hentai does not exist, create a new instance
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $request->input('description1')]
);
// Studio
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
// Create Episode(s)
$releasedEpisodes = [];
for ($i = 1; $i <= $request->input('episodes'); $i++) {
$episode = $this->episodeService->createEpisode($request, $hentai, $i, $studio);
$this->episodeService->createOrUpdateCover($request, $episode, $slug, $i);
$this->downloadService->createOrUpdateDownloads($request, $episode, $i);
$this->galleryService->createOrUpdateGallery($request, $hentai, $episode, $i);
$releasedEpisodes[] = $episode->slug;
}
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored');
} else {
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
}
}
cache()->flush();
return to_route('home.index');
}
} }
+1 -5
View File
@@ -56,14 +56,11 @@ class StreamController extends Controller
// Playlist // Playlist
if ($request->has('playlist')) { if ($request->has('playlist')) {
// Get and check if playlist exists // Get and check if playlist exists
$playlist = Playlist::where('id', $request->input('playlist'))->firstOrFail(); $playlist = Playlist::withCount('episodes')->where('id', $request->input('playlist'))->firstOrFail();
// Check if episode is in playlist // Check if episode is in playlist
$inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail(); $inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail();
// Get Playlist Episodes and order them
$playlistEpisodes = $playlist->episodes()->orderBy('position')->get();
// Check if authorized // Check if authorized
if ($playlist->is_private && (Auth::guest() || (! Auth::guest() && Auth::user()->id != $playlist->user_id))) { if ($playlist->is_private && (Auth::guest() || (! Auth::guest() && Auth::user()->id != $playlist->user_id))) {
abort(404); abort(404);
@@ -75,7 +72,6 @@ class StreamController extends Controller
'studioEpisodes' => $studioEpisodes, 'studioEpisodes' => $studioEpisodes,
'gallery' => $gallery, 'gallery' => $gallery,
'playlist' => $playlist, 'playlist' => $playlist,
'playlistEpisodes' => $playlistEpisodes,
'popularWeekly' => CacheHelper::getPopularWeekly(), 'popularWeekly' => CacheHelper::getPopularWeekly(),
'isMobile' => $isMobile, 'isMobile' => $isMobile,
]); ]);
+5
View File
@@ -31,6 +31,11 @@ class DiscordReleaseNotification implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
// Discord notifications must only ever be sent in production.
if (! app()->isProduction()) {
return;
}
switch ($this->messageType) { switch ($this->messageType) {
case 'release': case 'release':
DiscordAlert::message('<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/'.$this->slug); DiscordAlert::message('<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/'.$this->slug);
+284
View File
@@ -0,0 +1,284 @@
<?php
namespace App\Livewire;
use App\Enums\UserRole;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Episode;
use App\Services\CdnPathValidator;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class AdminEpisodeForm extends Component
{
use WithFileUploads;
public int $referenceEpisodeId;
public int $episodeNumber;
public string $title = '';
public string $titleJpn = '';
public string $studio = '';
/** @var string[] */
public array $tags = [];
public string $releasedate = '';
public string $baseurl = '';
public string $description = '';
public ?TemporaryUploadedFile $cover = null;
/** @var TemporaryUploadedFile[] */
public array $gallery = [];
/** @var array{fhd: string, fhdi: string, uhd: string, uhdi: string} */
public array $downloads = ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''];
public bool $censored = false;
/** @var array<string, array{state: string, message: string, mirrors: array}> */
public array $validation = [];
public bool $overrideValidation = false;
public bool $saving = false;
public function mount(int $episodeId): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$reference = Episode::with(['hentai', 'studio'])->findOrFail($episodeId);
$this->referenceEpisodeId = $reference->id;
$this->episodeNumber = $reference->hentai->episodes()->count() + 1;
$this->title = $reference->title;
$this->titleJpn = $reference->title_jpn;
$this->studio = $reference->studio?->name ?? '';
$this->tags = $reference->tags->pluck('name')->all();
$this->releasedate = $reference->release_date
? Carbon::parse($reference->release_date)->format('Y-m-d')
: now()->format('Y-m-d');
$this->baseurl = preg_replace('#/E\d+$#', '', $reference->url) ?? '';
$this->description = $reference->description;
}
public function getHasInvalidValidationProperty(): bool
{
foreach ($this->validation as $status) {
if (($status['state'] ?? '') === 'invalid') {
return true;
}
}
return false;
}
public function updatedBaseurl(): void
{
$this->validateStream();
}
public function updatedDownloads(mixed $value, string $key): void
{
if (preg_match('/^(fhd|fhdi|uhd|uhdi)$/', $key)) {
$this->validateDownload($key);
}
}
public function validateStream(): void
{
$baseurl = trim($this->baseurl);
if ($baseurl === '') {
$this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateStream($baseurl, 1, false, $this->episodeNumber);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function validateDownload(string $quality): void
{
$key = "downloads.{$quality}";
$url = $this->downloads[$quality] ?? '';
if (trim($url) === '') {
$this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function save(): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$this->validate([
'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'],
'description' => 'required|string',
'cover' => 'required|image|max:20480',
'gallery.*' => 'nullable|image|max:20480',
'downloads.fhd' => 'required|string',
'downloads.uhd' => 'required|string',
'downloads.fhdi' => 'nullable|string',
'downloads.uhdi' => 'nullable|string',
]);
$this->saving = true;
try {
// Re-load the reference episode fresh so the episode number reflects
// the current episode count rather than the mount-time value.
$referenceEpisode = Episode::with(['hentai', 'studio'])->findOrFail($this->referenceEpisodeId);
$hentai = $referenceEpisode->hentai;
$this->episodeNumber = $hentai->episodes()->count() + 1;
// Re-validate every non-empty download path + stream against the CDN.
$validator = app(CdnPathValidator::class);
$sizes = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$key = "downloads.{$quality}";
$result = $validator->validateDownload($type, $url, true);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
$sizes[$type] = $result['size'];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError($key, 'Path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
$result = $validator->validateStream($this->baseurl, 1, true, $this->episodeNumber);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError('baseurl', 'Stream path not found on all CDN mirrors.');
$this->saving = false;
return;
}
// Persist.
$episodeService = app(EpisodeService::class);
$studio = $episodeService->getOrCreateStudio(trim($this->studio));
$episodeModel = $episodeService->createEpisodeFromArray([
'title' => $this->title,
'title_jpn' => $this->titleJpn,
'baseurl' => $this->baseurl,
'description' => $this->description,
'releasedate' => $this->releasedate,
'tags' => $this->tags,
'interpolated_uhd' => trim($this->downloads['uhdi'] ?? '') !== '',
], $hentai, $this->episodeNumber, $studio);
if ($this->cover) {
$episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $this->episodeNumber, $this->cover);
}
if (! empty($this->gallery)) {
app(GalleryService::class)->saveGalleryFiles($hentai, $episodeModel, $this->episodeNumber, $this->gallery);
}
$downloads = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$downloads[$type] = [
'url' => trim($url),
'size' => $sizes[$type] ?? null,
];
}
app(DownloadService::class)->createOrUpdateDownloadsFromArray($episodeModel, $downloads);
// Discord notifications + cache flush.
if ($this->censored) {
DiscordReleaseNotification::dispatch($this->title.' - '.$this->episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episodeModel->slug, 'release');
}
cache()->flush();
} catch (\Throwable $e) {
Log::error('Failed to create episode from Livewire form: '.$e->getMessage(), [
'exception' => $e,
'referenceEpisodeId' => $this->referenceEpisodeId,
]);
$this->saving = false;
$this->addError('description', 'Something went wrong while saving. Please try again.');
return;
}
$this->redirectRoute('hentai.index', ['title' => $episodeModel->slug]);
}
private function qualityToType(string $quality): string
{
return match ($quality) {
'fhd' => 'FHD',
'fhdi' => 'FHDi',
'uhd' => 'UHD',
'uhdi' => 'UHDi',
};
}
public function render()
{
return view('livewire.admin-episode-form');
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace App\Livewire;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Hentai;
use App\Services\CdnPathValidator;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class AdminReleaseForm extends Component
{
use WithFileUploads;
public string $title = '';
public string $titleJpn = '';
public string $studio = '';
/** @var string[] */
public array $tags = [];
public string $releasedate = '';
public string $baseurl = '';
public bool $censored = false;
public int $episodeCount = 1;
/** @var array<int, array{description: string, cover: TemporaryUploadedFile|null, gallery: TemporaryUploadedFile[], downloads: array{fhd: string, fhdi: string, uhd: string, uhdi: string}}> */
public array $episodes = [];
/** @var array<string, array{state: string, message: string, mirrors: array}> */
public array $validation = [];
public bool $overrideValidation = false;
public bool $saving = false;
public function mount(): void
{
$this->episodes = [$this->defaultEpisode()];
}
private function defaultEpisode(): array
{
return [
'description' => '',
'cover' => null,
'gallery' => [],
'downloads' => ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''],
];
}
public function addEpisode(): void
{
$this->episodes[] = $this->defaultEpisode();
$this->episodeCount = count($this->episodes);
if (trim($this->baseurl) !== '') {
$this->validateStream();
}
}
public function removeEpisode(int $index): void
{
if (count($this->episodes) <= 1) {
return;
}
unset($this->episodes[$index]);
$this->episodes = array_values($this->episodes);
$this->episodeCount = count($this->episodes);
$this->pruneValidation();
if (trim($this->baseurl) !== '') {
$this->validateStream();
}
}
public function getHasInvalidValidationProperty(): bool
{
foreach ($this->validation as $status) {
if (($status['state'] ?? '') === 'invalid') {
return true;
}
}
return false;
}
public function updatedEpisodes(mixed $value, string $key): void
{
if (preg_match('/^(\d+)\.downloads\.(fhd|fhdi|uhd|uhdi)$/', $key, $matches)) {
$this->validateDownload((int) $matches[1], $matches[2]);
}
}
public function updatedBaseurl(): void
{
$this->validateStream();
}
public function validateDownload(int $episodeIndex, string $quality): void
{
$key = "episodes.{$episodeIndex}.downloads.{$quality}";
$url = $this->episodes[$episodeIndex]['downloads'][$quality] ?? '';
if (trim($url) === '') {
$this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function validateStream(): void
{
$baseurl = trim($this->baseurl);
if ($baseurl === '') {
$this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateStream($baseurl, count($this->episodes));
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function save(): void
{
$this->validate([
'title' => 'required|string|max:255',
'titleJpn' => 'required|string|max:255',
'studio' => 'required|string|max:255',
'tags' => 'required|array|min:1',
'releasedate' => 'required|date',
'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'],
'episodes.*.description' => 'required|string',
'episodes.*.cover' => 'required|image|max:20480',
'episodes.*.gallery.*' => 'nullable|image|max:20480',
'episodes.*.downloads.fhd' => 'required|string',
'episodes.*.downloads.uhd' => 'required|string',
]);
$this->saving = true;
// 1) Re-validate every non-empty download path + stream against the CDN.
$validator = app(CdnPathValidator::class);
$sizes = [];
foreach ($this->episodes as $index => $episode) {
foreach ($episode['downloads'] as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$key = "episodes.{$index}.downloads.{$quality}";
$result = $validator->validateDownload($type, $url, true);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
$sizes[$index][$type] = $result['size'];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError($key, 'Path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
}
if (trim($this->baseurl) !== '') {
$result = $validator->validateStream($this->baseurl, count($this->episodes), true);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError('baseurl', 'Stream path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
// 2) Duplicate-guard: block re-releasing a title created today.
$episodeService = app(EpisodeService::class);
$slug = $episodeService->generateSlug($this->title);
$existing = Hentai::where('slug', $slug)->first();
if ($existing && $existing->created_at->isToday()) {
$this->addError('title', 'A release with this title was already created today. Refine the title to avoid a duplicate.');
$this->saving = false;
return;
}
try {
// 3) Persist.
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $this->episodes[0]['description']]
);
$studio = $episodeService->getOrCreateStudio(trim($this->studio));
$galleryService = app(GalleryService::class);
$downloadService = app(DownloadService::class);
$releasedEpisodes = [];
foreach ($this->episodes as $index => $episode) {
$episodeNumber = $index + 1;
$episodeModel = $episodeService->createEpisodeFromArray([
'title' => $this->title,
'title_jpn' => $this->titleJpn,
'baseurl' => $this->baseurl,
'description' => $episode['description'],
'releasedate' => $this->releasedate,
'tags' => $this->tags,
'interpolated_uhd' => trim($episode['downloads']['uhdi'] ?? '') !== '',
], $hentai, $episodeNumber, $studio);
if ($episode['cover']) {
$episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $episodeNumber, $episode['cover']);
}
if (! empty($episode['gallery'])) {
$galleryService->saveGalleryFiles($hentai, $episodeModel, $episodeNumber, $episode['gallery']);
}
$downloads = [];
foreach ($episode['downloads'] as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$downloads[$type] = [
'url' => trim($url),
'size' => $sizes[$index][$type] ?? null,
];
}
$downloadService->createOrUpdateDownloadsFromArray($episodeModel, $downloads);
$releasedEpisodes[] = $episodeModel->slug;
}
// 4) Discord notifications + cache flush.
if ($this->censored) {
DiscordReleaseNotification::dispatch($this->title, 'release-censored');
} else {
foreach ($releasedEpisodes as $episodeSlug) {
DiscordReleaseNotification::dispatch($episodeSlug, 'release');
}
}
cache()->flush();
} catch (\Throwable $e) {
Log::error('Failed to create release from Livewire form: '.$e->getMessage(), [
'exception' => $e,
'slug' => $slug,
]);
$this->saving = false;
$this->addError('title', 'Something went wrong while saving. Please try again.');
return;
}
// 5) Redirect.
$this->redirectRoute('home.index');
}
private function qualityToType(string $quality): string
{
return match ($quality) {
'fhd' => 'FHD',
'fhdi' => 'FHDi',
'uhd' => 'UHD',
'uhdi' => 'UHDi',
};
}
private function pruneValidation(): void
{
$count = count($this->episodes);
foreach (array_keys($this->validation) as $key) {
if (preg_match('/^episodes\.(\d+)\./', $key, $matches) && (int) $matches[1] >= $count) {
unset($this->validation[$key]);
}
}
}
public function render()
{
return view('livewire.admin-release-form');
}
}
+87 -41
View File
@@ -2,10 +2,11 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistEpisode; use App\Models\PlaylistEpisode;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
@@ -18,14 +19,13 @@ class PlaylistOverview extends Component
protected PlaylistService $playlistService; protected PlaylistService $playlistService;
#[Url(history: true)] #[Url(history: true)]
public $search; public string $search = '';
public int $pagination = 25; #[Url(history: true)]
public int $perPage = 25;
public Playlist $playlist; public Playlist $playlist;
public Collection $playlistEpisodes;
public bool $editingName = false; public bool $editingName = false;
public string $editingPlaylistName = ''; public string $editingPlaylistName = '';
@@ -37,35 +37,56 @@ class PlaylistOverview extends Component
public function mount($playlist_id) public function mount($playlist_id)
{ {
$this->playlist = Playlist::with(['episodes.episode'])->findOrFail($playlist_id); $this->playlist = Playlist::withCount('episodes')->with('user')->findOrFail($playlist_id);
// Set position if null $this->sanitizePerPage();
$this->playlist->episodes->each(function ($item, $index) {
if ($item->position === null) {
$item->position = $index + 1;
$item->save();
}
});
$this->refreshEpisodes(); $this->repairNullPositions();
} }
public function refreshEpisodes() public function updatingSearch(): void
{ {
$this->playlistEpisodes = $this->playlist->episodes()->orderBy('position')->with('episode')->get(); $this->resetPage();
}
public function updatingPerPage(): void
{
$this->resetPage();
}
public function getEpisodesProperty(): LengthAwarePaginator
{
$this->sanitizePerPage();
return PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->when($this->search !== '', fn ($query) => $query->whereHas('episode', fn ($episode) => $episode
->where('title', 'like', '%'.$this->search.'%')
->orWhere('title_jpn', 'like', '%'.$this->search.'%')))
->orderBy('position')
->with(['episode.gallery' => fn ($gallery) => $gallery->orderBy('id')->limit(1)])
->paginate($this->perPage);
}
public function getFirstEpisodeProperty(): ?Episode
{
return PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')
->first()?->episode;
} }
public function moveUp($episodeId) public function moveUp($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
$episode = PlaylistEpisode::find($episodeId); $episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id) $above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '<', $episode->position) ->where('position', '<', $episode->position)
->orderBy('position', 'desc') ->orderBy('position', 'desc')
@@ -74,21 +95,20 @@ class PlaylistOverview extends Component
if ($above) { if ($above) {
$this->playlistService->swapPositions($episode, $above); $this->playlistService->swapPositions($episode, $above);
} }
$this->refreshEpisodes();
} }
public function moveDown($episodeId) public function moveDown($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
$episode = PlaylistEpisode::find($episodeId); $episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id) $below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '>', $episode->position) ->where('position', '>', $episode->position)
->orderBy('position') ->orderBy('position')
@@ -97,28 +117,28 @@ class PlaylistOverview extends Component
if ($below) { if ($below) {
$this->playlistService->swapPositions($episode, $below); $this->playlistService->swapPositions($episode, $below);
} }
$this->refreshEpisodes();
} }
public function remove($episodeId) public function remove($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
PlaylistEpisode::find($episodeId)?->delete(); PlaylistEpisode::find($episodeId)?->delete();
$this->playlistService->reorderPositions($this->playlist); $this->playlistService->reorderPositions($this->playlist);
$this->refreshEpisodes(); $this->playlist->loadCount('episodes');
$lastPage = max(1, (int) ceil(PlaylistEpisode::where('playlist_id', $this->playlist->id)->count() / $this->perPage));
if ($this->getPage() > $lastPage) {
$this->setPage($lastPage);
}
} }
public function editName() public function editName()
{ {
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) { if (! $this->isOwner()) {
return; return;
} }
@@ -134,7 +154,7 @@ class PlaylistOverview extends Component
public function updateName() public function updateName()
{ {
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) { if (! $this->isOwner()) {
return; return;
} }
@@ -152,7 +172,7 @@ class PlaylistOverview extends Component
public function toggleVisibility() public function toggleVisibility()
{ {
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) { if (! $this->isOwner()) {
return; return;
} }
@@ -160,13 +180,39 @@ class PlaylistOverview extends Component
'is_private' => ! $this->playlist->is_private, 'is_private' => ! $this->playlist->is_private,
]); ]);
$this->playlist->refresh(); $this->playlist->loadCount('episodes');
} }
public function render() public function render()
{ {
return view('livewire.playlist-overview', [ return view('livewire.playlist-overview', [
'query' => $this->search, 'episodes' => $this->episodes,
'firstEpisode' => $this->firstEpisode,
'isOwner' => $this->isOwner(),
]); ]);
} }
private function isOwner(): bool
{
return Auth::check() && Auth::user()->id === $this->playlist->user_id;
}
private function sanitizePerPage(): void
{
if (! in_array($this->perPage, [25, 50, 100], true)) {
$this->perPage = 25;
}
}
private function repairNullPositions(): void
{
if (! PlaylistEpisode::where('playlist_id', $this->playlist->id)->whereNull('position')->exists()) {
return;
}
PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')->orderBy('id')
->get()
->each(fn ($playlistEpisode, $index) => $playlistEpisode->update(['position' => $index + 1]));
}
} }
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Services\PlaylistService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class PlaylistSidebar extends Component
{
public const PAGE_SIZE = 20;
public const MAX_WINDOW = 60;
protected PlaylistService $playlistService;
public Playlist $playlist;
public Episode $currentEpisode;
public int $currentEpisodeId;
public int $total = 0;
public int $windowStart = 1;
public int $windowEnd = 0;
public bool $isOwner = false;
public bool $collapsible = false;
public ?string $previousEpisodeSlug = null;
public ?string $nextEpisodeSlug = null;
public function boot(PlaylistService $playlistService): void
{
$this->playlistService = $playlistService;
}
public function mount(int $playlistId, int $currentEpisodeId, bool $collapsible = false): void
{
$this->playlist = Playlist::with('user')->withCount('episodes')->findOrFail($playlistId);
$this->currentEpisodeId = $currentEpisodeId;
$this->currentEpisode = Episode::with(['gallery' => fn ($query) => $query->orderBy('id')->limit(1)])
->findOrFail($currentEpisodeId);
$this->total = $this->playlist->episodes_count;
$this->collapsible = $collapsible;
$this->isOwner = Auth::check() && Auth::user()->id === $this->playlist->user_id;
$this->repairNullPositions();
$position = $this->currentPosition() ?? 1;
$this->setWindowAround($position);
$this->resolveAdjacentSlugs($position);
}
public function getEpisodesProperty(): Collection
{
return PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->whereBetween('position', [$this->windowStart, $this->windowEnd])
->orderBy('position')
->with([
'episode.gallery' => fn ($gallery) => $gallery->orderBy('id')->limit(1),
'episode.studio',
])
->get();
}
public function appendChunk(): void
{
if ($this->windowEnd >= $this->total) {
return;
}
$this->windowEnd = min($this->total, $this->windowEnd + self::PAGE_SIZE);
$this->windowStart = max(1, $this->windowEnd - self::MAX_WINDOW + 1);
}
public function prependChunk(): void
{
if ($this->windowStart <= 1) {
return;
}
$this->windowStart = max(1, $this->windowStart - self::PAGE_SIZE);
$this->windowEnd = min($this->total, $this->windowStart + self::MAX_WINDOW - 1);
}
public function remove(int $playlistEpisodeId): void
{
if (! $this->isOwner) {
return;
}
$playlistEpisode = PlaylistEpisode::find($playlistEpisodeId);
if (! $playlistEpisode) {
return;
}
$playlistEpisode->delete();
$this->playlistService->reorderPositions($this->playlist);
$this->playlist->loadCount('episodes');
$this->total = $this->playlist->episodes_count;
$this->windowEnd = min($this->windowEnd, max(1, $this->total));
$this->windowStart = min($this->windowStart, max(1, $this->total));
$position = $this->currentPosition();
if ($position) {
$this->resolveAdjacentSlugs($position);
}
}
public function render(): View
{
$activePlaylistEpisode = $this->activePlaylistEpisode();
return view('livewire.playlist-sidebar', [
'episodes' => $this->episodes,
'currentPosition' => $activePlaylistEpisode?->position ?? 1,
'currentPlaylistEpisodeId' => $activePlaylistEpisode?->id,
'isOwner' => $this->isOwner,
]);
}
private function activePlaylistEpisode(): ?PlaylistEpisode
{
return PlaylistEpisode::where('playlist_id', $this->playlist->id)
->where('episode_id', $this->currentEpisodeId)
->first();
}
private function currentPosition(): ?int
{
return $this->activePlaylistEpisode()?->position;
}
private function setWindowAround(int $position): void
{
$this->windowStart = max(1, $position - self::PAGE_SIZE);
$this->windowEnd = min($this->total, $position + self::PAGE_SIZE);
}
private function resolveAdjacentSlugs(int $position): void
{
$adjacent = PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->whereBetween('position', [$position - 1, $position + 1])
->orderBy('position')
->with('episode')
->get();
$slugsByPosition = $adjacent->keyBy('position');
$this->previousEpisodeSlug = $slugsByPosition->get($position - 1)?->episode->slug;
$this->nextEpisodeSlug = $slugsByPosition->get($position + 1)?->episode->slug;
}
private function repairNullPositions(): void
{
if (! PlaylistEpisode::where('playlist_id', $this->playlist->id)->whereNull('position')->exists()) {
return;
}
PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')->orderBy('id')
->get()
->each(fn ($playlistEpisode, $index) => $playlistEpisode->update(['position' => $index + 1]));
}
}
+20
View File
@@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Playlist; use App\Models\Playlist;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -54,11 +55,30 @@ class Playlists extends Component
->withCount('episodes') ->withCount('episodes')
->having('episodes_count', '>', 1) ->having('episodes_count', '>', 1)
->when($this->search != '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%')) ->when($this->search != '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->with([
'user',
'episodes' => fn ($query) => $query->orderBy('position')->limit(4),
'episodes.episode.gallery' => fn ($query) => $query->limit(1),
])
->orderBy($orderby, $orderdirection) ->orderBy($orderby, $orderdirection)
->paginate($this->pagination); ->paginate($this->pagination);
$stats = Cache::remember('publicPlaylistStats', 600, function () {
$playlists = Playlist::where('is_private', 0)
->withCount('episodes')
->having('episodes_count', '>', 1)
->get();
return [
'playlists' => $playlists->count(),
'episodes' => $playlists->sum('episodes_count'),
];
});
return view('livewire.playlists', [ return view('livewire.playlists', [
'playlists' => $playlists, 'playlists' => $playlists,
'totalPlaylists' => $stats['playlists'],
'totalEpisodes' => $stats['episodes'],
]); ]);
} }
} }
+2
View File
@@ -18,6 +18,8 @@ class Downloads extends Model
'episode_id', 'episode_id',
'type', 'type',
'url', 'url',
'size',
'validated_at',
]; ];
/** /**
+10
View File
@@ -2,12 +2,22 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Gallery extends Model class Gallery extends Model
{ {
use HasFactory;
public $table = 'gallery'; public $table = 'gallery';
/**
* The attributes that aren't mass assignable.
*
* @var array<int, string>
*/
protected $guarded = [];
/** /**
* Belongs To Episode. * Belongs To Episode.
*/ */
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Playlist extends Model class Playlist extends Model
{ {
use HasFactory;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlaylistEpisode extends Model class PlaylistEpisode extends Model
{ {
use HasFactory;
/** /**
* Indicates If The Model Should Be Timestamped. * Indicates If The Model Should Be Timestamped.
* *
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
class CdnPathValidator
{
public const DOWNLOAD_PREFIX = [
'FHD' => 'hentai-1080p/',
'FHDi' => 'hentai-1080p/',
'UHD' => 'hentai/',
'UHDi' => 'hentai/',
];
private const STREAM_PREFIX = 'hentai-stream/';
private const CACHE_TTL = 300;
public function validateDownload(string $type, string $url, bool $fresh = false): array
{
$path = self::DOWNLOAD_PREFIX[$type].ltrim($url, '/');
$domains = config('hstream.download_domain_4k');
return $this->checkMirrors($domains, [$path], $fresh);
}
public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false, int $episodeNumber = 1): array
{
$domains = config('hstream.download_domain_4k');
// Validate the base directory, plus the episode manifest as a sanity check.
$paths = [self::STREAM_PREFIX.rtrim($baseurl, '/')];
if ($episodeCount >= 1) {
$paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd';
}
return $this->checkMirrors($domains, $paths, $fresh);
}
private function checkMirrors(array $domains, array $paths, bool $fresh = false): array
{
$cacheKey = 'cdncheck:'.sha1(serialize([$domains, $paths]));
if ($fresh) {
Cache::forget($cacheKey);
}
return Cache::remember($cacheKey, self::CACHE_TTL, fn () => $this->runChecks($domains, $paths));
}
private function runChecks(array $domains, array $paths): array
{
$entries = [];
foreach ($domains as $domain) {
foreach ($paths as $path) {
$entries[] = ['domain' => $domain, 'path' => $path];
}
}
try {
$responses = Http::pool(function ($pool) use ($entries) {
foreach ($entries as $entry) {
$pool->timeout(4)
->withHeaders(['Accept' => 'application/json'])
->get($entry['domain'].'/check/'.$this->tokenize($entry['path']).'/'.$this->tokenize(now()->addHours(6)));
}
});
} catch (\Throwable $e) {
$mirrors = [];
foreach ($entries as $entry) {
$mirrors[$entry['domain']][$entry['path']] = false;
}
return [
'valid' => false,
'mirrors' => $mirrors,
'size' => null,
];
}
$mirrors = [];
$allValid = true;
$firstSize = null;
foreach ($entries as $index => $entry) {
$response = $responses[$index];
$ok = $response instanceof Response
&& $response->ok()
&& $response->json('valid') === true;
$mirrors[$entry['domain']][$entry['path']] = $ok;
if (! $ok) {
$allValid = false;
} elseif ($firstSize === null) {
$firstSize = $response->json('size');
}
}
return [
'valid' => $allValid,
'mirrors' => $mirrors,
'size' => $allValid ? $firstSize : null,
];
}
private function tokenize(string $value): string
{
return urlencode(Crypt::encryptString($value));
}
}
+24
View File
@@ -33,4 +33,28 @@ class DownloadService
} }
} }
} }
/**
* Persist validated download URLs for an episode. Sizes are written
* directly from the CDN validation response, so no size job is dispatched.
*
* @param array<string, array{url: string, size?: float|int|null}> $downloads
*/
public function createOrUpdateDownloadsFromArray(Episode $episode, array $downloads): void
{
foreach ($downloads as $type => $data) {
if (empty($data['url'])) {
continue;
}
Downloads::updateOrCreate([
'episode_id' => $episode->id,
'type' => $type,
], [
'url' => $data['url'],
'size' => $data['size'] ?? null,
'validated_at' => now(),
]);
}
}
} }
+45 -35
View File
@@ -4,14 +4,16 @@ namespace App\Services;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\Studios;
use App\Models\ModLog; use App\Models\ModLog;
use App\Models\Studios;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image; use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EpisodeService class EpisodeService
{ {
@@ -30,39 +32,6 @@ class EpisodeService
return $slug; return $slug;
} }
public function createEpisode(
Request $request,
Hentai $hentai,
int $episodeNumber,
?Studios $studio = null,
?Episode $referenceEpisode = null
): Episode {
$episode = new Episode;
$episode->title = $referenceEpisode->title ?? $request->input('title');
$episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title);
$episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn');
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $referenceEpisode->studio->id ?? $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}");
$episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->is_dvd_aspect = false;
$episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
// Tagging
$tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags'));
foreach ($tags as $t) {
$episode->tag($referenceEpisode ? $t->name : $t->value);
}
return $episode;
}
private function applyTags(Request $request, Episode $episode): void private function applyTags(Request $request, Episode $episode): void
{ {
$tags = json_decode($request->input('tags')); $tags = json_decode($request->input('tags'));
@@ -171,19 +140,60 @@ class EpisodeService
); );
} }
/**
* Create an episode from an array of data (used by the Livewire release form).
*
* @param array{title: string, title_jpn: string, baseurl: string, description: string, releasedate: string, tags: string[], interpolated_uhd?: bool} $data
*/
public function createEpisodeFromArray(
array $data,
Hentai $hentai,
int $episodeNumber,
?Studios $studio = null
): Episode {
$episode = new Episode;
$episode->title = $data['title'];
$episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title);
$episode->title_jpn = $data['title_jpn'];
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $data['description'];
$episode->url = rtrim($data['baseurl'], '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->interpolated_uhd = $data['interpolated_uhd'] ?? false;
$episode->is_dvd_aspect = false;
$episode->release_date = Carbon::parse($data['releasedate'])->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
foreach ($data['tags'] as $tag) {
$episode->tag($tag);
}
return $episode;
}
public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void
{ {
if (! $request->hasFile("episodecover{$episodeNumber}")) { if (! $request->hasFile("episodecover{$episodeNumber}")) {
return; return;
} }
$this->saveCoverFromFile($episode, $slug, $episodeNumber, $request->file("episodecover{$episodeNumber}"));
}
public function saveCoverFromFile(Episode $episode, string $slug, int $episodeNumber, UploadedFile|TemporaryUploadedFile $file): void
{
// Create Folder for Image Upload // Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) { if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}"); Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
} }
// Encode and save cover image // Encode and save cover image
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath()) Image::read($file->getRealPath())
->cover(268, 394) ->cover(268, 394)
->encode(new WebpEncoder) ->encode(new WebpEncoder)
->save(Storage::disk('public')->path($episode->cover_url)); ->save(Storage::disk('public')->path($episode->cover_url));
+20 -9
View File
@@ -6,9 +6,11 @@ use App\Models\Episode;
use App\Models\Gallery; use App\Models\Gallery;
use App\Models\Hentai; use App\Models\Hentai;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image; use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class GalleryService class GalleryService
{ {
@@ -17,17 +19,26 @@ class GalleryService
$galleryInputNumber = $override ? 1 : $episodeNumber; $galleryInputNumber = $override ? 1 : $episodeNumber;
if ($request->hasFile('episodegallery'.$galleryInputNumber)) { if ($request->hasFile('episodegallery'.$galleryInputNumber)) {
$this->saveGalleryFiles($hentai, $episode, $episodeNumber, $request->file('episodegallery'.$galleryInputNumber));
}
}
$this->deleteOldGallery($episode); /**
* Save a gallery for the given episode from a list of uploaded files.
*
* @param array<int, UploadedFile|TemporaryUploadedFile> $files
*/
public function saveGalleryFiles(Hentai $hentai, Episode $episode, int $episodeNumber, array $files): void
{
$this->deleteOldGallery($episode);
$this->createGalleryFolder($hentai); $this->createGalleryFolder($hentai);
$counter = 0; $counter = 0;
foreach ($request->file('episodegallery'.$galleryInputNumber) as $file) { foreach ($files as $file) {
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter); $gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
$this->saveGalleryImage($gallery, $file); $this->saveGalleryImage($gallery, $file);
$counter += 1; $counter += 1;
}
} }
} }
@@ -51,7 +62,7 @@ class GalleryService
return $gallery; return $gallery;
} }
private function saveGalleryImage(Gallery $gallery, $sourceImage): void private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void
{ {
Image::read($sourceImage->getRealPath()) Image::read($sourceImage->getRealPath())
->cover(1920, 1080) ->cover(1920, 1080)
+17
View File
@@ -0,0 +1,17 @@
{
"agents": [
"cursor"
],
"cloud": false,
"guidelines": true,
"mcp": true,
"nightwatch": false,
"sail": false,
"skills": [
"laravel-best-practices",
"scout-development",
"socialite-development",
"livewire-development",
"tailwindcss-development"
]
}
+1
View File
@@ -34,6 +34,7 @@
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.16", "barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.24.0", "fakerphp/faker": "^1.24.0",
"laravel/boost": "^2.4",
"laravel/breeze": "^2.3", "laravel/breeze": "^2.3",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
Generated
+278 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "bfeb75482defef9826c45252382f7e6d", "content-hash": "636d4675deabe7605859a7873be3d200",
"packages": [ "packages": [
{ {
"name": "altcha-org/altcha", "name": "altcha-org/altcha",
@@ -10138,6 +10138,72 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "time": "2025-04-30T06:54:44+00:00"
}, },
{
"name": "laravel/boost",
"version": "v2.4.13",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "f55e08f5afa89ac72f23f574175005b67878f466"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/f55e08f5afa89ac72f23f574175005b67878f466",
"reference": "f55e08f5afa89ac72f23f574175005b67878f466",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2026-07-17T14:28:57+00:00"
},
{ {
"name": "laravel/breeze", "name": "laravel/breeze",
"version": "v2.4.2", "version": "v2.4.2",
@@ -10199,6 +10265,80 @@
}, },
"time": "2026-05-14T16:54:25+00:00" "time": "2026-05-14T16:54:25+00:00"
}, },
{
"name": "laravel/mcp",
"version": "v0.9.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "a08884d79a95c5143498507aec5badf751cdbec4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/a08884d79a95c5143498507aec5badf751cdbec4",
"reference": "a08884d79a95c5143498507aec5badf751cdbec4",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2",
"symfony/process": "^7.4.5|^8.0.5"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-07-21T13:23:52+00:00"
},
{ {
"name": "laravel/pint", "name": "laravel/pint",
"version": "v1.29.1", "version": "v1.29.1",
@@ -10267,6 +10407,67 @@
}, },
"time": "2026-04-20T15:26:14+00:00" "time": "2026-04-20T15:26:14+00:00"
}, },
{
"name": "laravel/roster",
"version": "v0.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/routing": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2026-03-05T07:58:43+00:00"
},
{ {
"name": "mockery/mockery", "name": "mockery/mockery",
"version": "1.6.12", "version": "1.6.12",
@@ -12578,6 +12779,82 @@
], ],
"time": "2024-10-20T05:08:20+00:00" "time": "2024-10-20T05:08:20+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "8e4cdd4311683516be06944f4b85244063cdb886"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886",
"reference": "8e4cdd4311683516be06944f4b85244063cdb886",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<7.4"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"yaml/yaml-test-suite": "*"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-09T11:06:24+00:00"
},
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",
"version": "1.3.1", "version": "1.3.1",
+186
View File
@@ -0,0 +1,186 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
| In this case, the view returned by CreatePost will render into $slot.
|
*/
'layout' => 'components.layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'lazy_placeholder' => null,
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:51200'], // 20MB uploads for the release form covers/galleries
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => false,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
];
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Episode;
use App\Models\Gallery;
use App\Models\Hentai;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Gallery>
*/
class GalleryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'hentai_id' => Hentai::factory(),
'episode_id' => Episode::factory(),
'image_url' => $this->faker->url(),
'thumbnail_url' => $this->faker->url(),
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PlaylistEpisode>
*/
class PlaylistEpisodeFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'playlist_id' => Playlist::factory(),
'episode_id' => Episode::factory(),
'position' => $this->faker->numberBetween(1, 1000),
];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\Playlist;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Playlist>
*/
class PlaylistFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'name' => $this->faker->word(),
'is_private' => true,
];
}
}
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('downloads', function (Blueprint $table) {
$table->timestamp('validated_at')->nullable()->after('size');
});
Schema::table('hentais', function (Blueprint $table) {
$table->unique('slug');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('hentais', function (Blueprint $table) {
$table->dropUnique(['slug']);
});
Schema::table('downloads', function (Blueprint $table) {
$table->dropColumn('validated_at');
});
}
};
+548
View File
@@ -0,0 +1,548 @@
/*M!999999\- enable the sandbox mode */
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `alerts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `alerts` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`type` int(11) NOT NULL,
`text` text NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `comments`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `comments` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`commentable_type` varchar(255) NOT NULL,
`commentable_id` varchar(255) NOT NULL,
`body` text NOT NULL,
`parent_id` bigint(20) unsigned DEFAULT NULL,
`deleted_by_moderator_id` bigint(20) DEFAULT NULL,
`deleted_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `comments_commentable_type_commentable_id_index` (`commentable_type`,`commentable_id`),
KEY `comments_user_id_foreign` (`user_id`),
KEY `comments_parent_id_foreign` (`parent_id`),
CONSTRAINT `comments_parent_id_foreign` FOREIGN KEY (`parent_id`) REFERENCES `comments` (`id`) ON DELETE CASCADE,
CONSTRAINT `comments_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `contacts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `contacts` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`subject` varchar(255) NOT NULL,
`message` text NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `downloads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `downloads` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` bigint(20) unsigned NOT NULL,
`type` char(5) NOT NULL,
`url` varchar(255) NOT NULL,
`size` double DEFAULT NULL,
`validated_at` timestamp NULL DEFAULT NULL,
`count` bigint(20) NOT NULL DEFAULT 0,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `downloads_episode_id_type_unique` (`episode_id`,`type`),
KEY `downloads_episode_id_index` (`episode_id`),
CONSTRAINT `downloads_episode_id_foreign` FOREIGN KEY (`episode_id`) REFERENCES `episodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `episode_subtitles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `episode_subtitles` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` bigint(20) unsigned NOT NULL,
`subtitle_id` bigint(20) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `episode_subtitles_episode_id_index` (`episode_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `episodes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `episodes` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`title_search` varchar(255) NOT NULL,
`title_jpn` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL,
`hentai_id` int(11) NOT NULL,
`studios_id` int(11) NOT NULL,
`episode` int(11) NOT NULL,
`description` text NOT NULL,
`url` varchar(255) NOT NULL,
`cover_url` varchar(255) NOT NULL,
`view_count` bigint(20) unsigned NOT NULL DEFAULT 0,
`interpolated` tinyint(1) NOT NULL DEFAULT 0,
`interpolated_uhd` tinyint(1) NOT NULL DEFAULT 0,
`dmca_takedown` tinyint(1) NOT NULL DEFAULT 0,
`release_date` date DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
`is_dvd_aspect` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `episode_view_count_index` (`view_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `failed_jobs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `failed_jobs` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(255) NOT NULL,
`connection` text NOT NULL,
`queue` text NOT NULL,
`payload` longtext NOT NULL,
`exception` longtext NOT NULL,
`failed_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `failed_jobs_uuid_unique` (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `gallery`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `gallery` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`hentai_id` int(11) NOT NULL,
`episode_id` int(11) NOT NULL,
`image_url` varchar(255) NOT NULL,
`thumbnail_url` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `gallery_hentai_id_index` (`hentai_id`),
KEY `gallery_episode_id_index` (`episode_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `hentais`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `hentais` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`slug` varchar(255) NOT NULL,
`description` text NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `hentais_slug_unique` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `jobs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `jobs` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`queue` varchar(255) NOT NULL,
`payload` longtext NOT NULL,
`attempts` tinyint(3) unsigned NOT NULL,
`reserved_at` int(10) unsigned DEFAULT NULL,
`available_at` int(10) unsigned NOT NULL,
`created_at` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `jobs_queue_index` (`queue`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `markable_likes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `markable_likes` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`markable_type` varchar(255) NOT NULL,
`markable_id` bigint(20) unsigned NOT NULL,
`value` varchar(255) DEFAULT NULL,
`metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `markable_likes_markable_type_markable_id_index` (`markable_type`,`markable_id`),
KEY `markable_likes_user_id_foreign` (`user_id`),
CONSTRAINT `markable_likes_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `migrations` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`migration` varchar(255) NOT NULL,
`batch` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `mod_logs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `mod_logs` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`moderator` varchar(255) NOT NULL,
`data` text NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `notifications`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `notifications` (
`id` char(36) NOT NULL,
`type` varchar(255) NOT NULL,
`notifiable_type` varchar(255) NOT NULL,
`notifiable_id` bigint(20) unsigned NOT NULL,
`data` text NOT NULL,
`read_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `notifications_notifiable_type_notifiable_id_index` (`notifiable_type`,`notifiable_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `passkeys`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `passkeys` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`authenticatable_id` bigint(20) unsigned NOT NULL,
`name` text NOT NULL,
`credential_id` text NOT NULL,
`data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`data`)),
`last_used_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `passkeys_authenticatable_fk` (`authenticatable_id`),
CONSTRAINT `passkeys_authenticatable_fk` FOREIGN KEY (`authenticatable_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `password_reset_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `password_reset_tokens` (
`email` varchar(255) NOT NULL,
`token` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `playlist_episodes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `playlist_episodes` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`position` int(11) DEFAULT NULL,
`playlist_id` bigint(20) unsigned NOT NULL,
`episode_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `playlist_episodes_playlist_id_episode_id_unique` (`playlist_id`,`episode_id`),
KEY `playlist_episodes_playlist_id_index` (`playlist_id`),
CONSTRAINT `playlist_episodes_playlist_id_foreign` FOREIGN KEY (`playlist_id`) REFERENCES `playlists` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `playlists`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `playlists` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`is_private` tinyint(1) NOT NULL DEFAULT 1,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `playlists_user_id_index` (`user_id`),
CONSTRAINT `playlists_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `popular_daily`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `popular_daily` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `popular_monthly`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `popular_monthly` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `popular_weekly`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `popular_weekly` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `site_backgrounds`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `site_backgrounds` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`date_start` date NOT NULL,
`date_end` date NOT NULL,
`default` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `studios`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `studios` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` tinytext NOT NULL,
`slug` tinytext NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `subtitles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `subtitles` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` tinytext NOT NULL,
`slug` tinytext NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `tagging_tag_groups`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `tagging_tag_groups` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`slug` varchar(125) NOT NULL,
`name` varchar(125) NOT NULL,
PRIMARY KEY (`id`),
KEY `tagging_tag_groups_slug_index` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `tagging_tagged`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `tagging_tagged` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`taggable_id` int(10) unsigned NOT NULL,
`taggable_type` varchar(125) NOT NULL,
`tag_name` varchar(125) NOT NULL,
`tag_slug` varchar(125) NOT NULL,
PRIMARY KEY (`id`),
KEY `tagging_tagged_taggable_id_index` (`taggable_id`),
KEY `tagging_tagged_taggable_type_index` (`taggable_type`),
KEY `tagging_tagged_tag_slug_index` (`tag_slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `tagging_tags`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `tagging_tags` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`slug` varchar(125) NOT NULL,
`name` varchar(125) NOT NULL,
`suggest` tinyint(1) NOT NULL DEFAULT 0,
`count` int(10) unsigned NOT NULL DEFAULT 0,
`tag_group_id` int(10) unsigned DEFAULT NULL,
`description` text DEFAULT NULL,
`locale` varchar(5) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tagging_tags_slug_index` (`slug`),
KEY `tagging_tags_tag_group_id_foreign` (`tag_group_id`),
CONSTRAINT `tagging_tags_tag_group_id_foreign` FOREIGN KEY (`tag_group_id`) REFERENCES `tagging_tag_groups` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `user_downloads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_downloads` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`episode_id` bigint(20) unsigned NOT NULL,
`interpolated` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_downloads_user_id_index` (`user_id`),
KEY `user_downloads_episode_id_index` (`episode_id`),
CONSTRAINT `user_downloads_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`discord_id` bigint(20) unsigned DEFAULT NULL,
`matrix_id` varchar(255) DEFAULT NULL,
`name` varchar(255) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
`remember_token` varchar(100) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`discord_avatar` varchar(255) DEFAULT NULL,
`locale` varchar(10) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
`roles` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`roles`)),
`search_design` tinyint(1) NOT NULL DEFAULT 1,
`home_top_design` tinyint(1) NOT NULL DEFAULT 0,
`home_middle_design` tinyint(1) NOT NULL DEFAULT 1,
`tag_blacklist` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`tag_blacklist`)),
`downloads_left` tinyint(1) NOT NULL DEFAULT 5,
PRIMARY KEY (`id`),
UNIQUE KEY `users_email_unique` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `video_engagement`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `video_engagement` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`episode_id` bigint(20) unsigned NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`segment` smallint(5) unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `video_engagement_episode_id_user_id_segment_unique` (`episode_id`,`user_id`,`segment`),
KEY `video_engagement_user_id_foreign` (`user_id`),
CONSTRAINT `video_engagement_episode_id_foreign` FOREIGN KEY (`episode_id`) REFERENCES `episodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `video_engagement_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `watched`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `watched` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`episode_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `watched_user_id_index` (`user_id`),
CONSTRAINT `watched_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
/*M!999999\- enable the sandbox mode */
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (1,'2014_01_07_073615_create_tagged_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (2,'2014_01_07_073615_create_tags_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (3,'2014_10_12_000000_create_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (4,'2014_10_12_100000_create_password_reset_tokens_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (5,'2016_06_29_073615_create_tag_groups_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (6,'2016_06_29_073615_update_tags_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (7,'2018_06_30_113500_create_comments_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (8,'2019_08_19_000000_create_failed_jobs_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (9,'2019_12_14_000001_create_personal_access_tokens_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (10,'2020_03_13_083515_add_description_to_tags_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (11,'2022_07_30_160525_create_contact_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (12,'2023_01_05_142617_update_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (13,'2023_04_06_101123_add_roles_to_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (14,'2023_05_25_121158_add_remember_token_to_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (15,'2023_05_26_165816_create_discord_access_tokens_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (16,'2023_05_27_055058_remove_refresh_token_from_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (17,'2023_06_11_062809_update_users_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (18,'2023_08_10_145324_create_likes_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (19,'2023_08_10_145325_add_hstream_base_tables',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (20,'2023_08_10_145326_import_old_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (21,'2023_08_10_190926_update_hstream_tables',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (22,'2023_08_12_130605_add_user_settings',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (23,'2023_08_13_200327_create_playlist_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (24,'2023_08_15_170207_create_alerts_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (25,'2023_10_03_150330_add_patreon_to_users_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (26,'2023_10_03_152048_add_4k_downloads_to_episode_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (27,'2023_10_03_230727_migrate_4k_downloads',3);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (28,'2023_11_30_150101_create_jobs_table',4);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (29,'2023_12_19_200302_created_watched_table',5);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (30,'2024_01_07_184928_add_blacklist_to_users_table',6);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (31,'2024_02_06_110910_add_interpolated_to_episode_table',7);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (32,'2024_02_18_110521_optimization',8);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (33,'2024_02_29_110046_create_torrents_table',9);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (34,'2024_03_05_205036_create_subtitles_table',10);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (35,'2024_03_05_211627_create_episode_subtitles_table',10);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (36,'2024_04_05_123805_add_description_to_episode_table',11);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (37,'2024_04_05_145706_rename_tables',11);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (38,'2024_05_10_135107_add_interpolated_download_to_episodes_table',12);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (39,'2024_06_25_141635_add_banned_to_users_table',13);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (40,'2024_07_15_143013_drop_legacy_stream_from_episodes_table',14);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (41,'2024_07_15_145809_drop_resolution_from_episodes_table',14);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (42,'2024_07_18_102838_add_aspect_ratio_to_episodes_table',14);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (43,'2024_07_29_134619_create_downloads_table',15);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (44,'2024_07_30_135107_add_count_to_downloads_table',15);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (45,'2024_07_30_194424_fix_slugs',15);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (46,'2024_10_13_131743_add_download_count_to_users_table',16);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (47,'2024_10_21_194317_add_title_search_to_epiosdes_table',17);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (48,'2024_10_27_102415_create_user_downloads_table',17);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (49,'2024_11_17_153334_create_site_backgrounds_table',18);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (50,'2024_11_25_163029_create_notifications_table',19);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (51,'2024_12_13_200252_add_interpolated_qhd_to_episodes_table',20);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (52,'2025_01_04_222655_add_interpolated_uhd_to_episodes_table',21);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (53,'2025_04_25_143357_fix_playlist_episode_positions',22);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (54,'2025_05_30_203455_fix_discord_oauth',22);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (55,'2025_09_21_215923_add_unique_constraint_to_downloads_table',23);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (56,'2025_09_22_113103_drop_torrents_table',23);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (57,'2025_12_19_205600_add_dmca_to_episodes_table',24);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (58,'2026_01_06_161620_fix_discord_oauth_system',25);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (59,'2026_01_08_213625_fix_database_structure',25);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (60,'2026_01_10_120521_migrate_comments_table',26);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (61,'2023_04_22_143828_add_locale_to_tagging_tags_table',27);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (62,'2026_01_11_151205_add_locale_to_users_table',27);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (63,'2026_01_11_184725_migrate_to_user_roles',27);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (64,'2026_02_17_222326_add_matrix_id_to_users_table',28);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (65,'2026_04_21_131052_create_passkeys_table',29);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (66,'2026_05_04_135331_add_subscription_key_to_users_table',30);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (67,'2026_05_06_144901_add_deleted_by_moderator_to_comments_table',31);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (68,'2026_05_06_181115_create_mod_logs_table',31);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (73,'2026_07_18_115452_drop_subscription_key_from_users_table',32);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (74,'2026_07_19_141500_create_video_engagement_table',32);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (75,'2026_08_04_193201_add_validated_at_to_downloads_and_unique_slug_to_hentais',33);
+2
View File
@@ -2,6 +2,8 @@
return [ return [
'home' => 'Startseite', 'home' => 'Startseite',
'browse' => 'Stöbern',
'community' => 'Community',
'search' => 'Suche', 'search' => 'Suche',
'public-playlists' => 'Öffentliche Playlisten', 'public-playlists' => 'Öffentliche Playlisten',
'downloads' => 'Downloads', 'downloads' => 'Downloads',
+13
View File
@@ -2,8 +2,21 @@
return [ return [
'no-playlist-found' => 'Keine Playlisten gefunden!', 'no-playlist-found' => 'Keine Playlisten gefunden!',
'hero-subtitle' => 'Kuratierte Episodensammlungen von der Community erstellt.',
'by' => 'von',
'playlists-count' => 'Playlisten',
'clear-search' => 'Suche löschen',
'personal-playlists' => 'Persönliche Playlisten', 'personal-playlists' => 'Persönliche Playlisten',
'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.', 'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.',
'play' => 'Abspielen', 'play' => 'Abspielen',
'playlist' => 'Playlist', 'playlist' => 'Playlist',
'search-episodes' => 'Episoden durchsuchen',
'per-page' => 'Pro Seite',
'episodes' => 'Episoden',
'empty-playlist' => 'Diese Playlist ist leer.',
'no-matches' => 'Keine Episoden entsprechen deiner Suche.',
'watched' => 'Gesehen',
'remove' => 'Entfernen',
'now-playing' => 'Jetzt läuft',
'remove-confirm' => 'Diese Episode aus der Playlist entfernen?',
]; ];
+2
View File
@@ -2,6 +2,8 @@
return [ return [
'home' => 'Home', 'home' => 'Home',
'browse' => 'Browse',
'community' => 'Community',
'search' => 'Search', 'search' => 'Search',
'public-playlists' => 'Public Playlists', 'public-playlists' => 'Public Playlists',
'downloads' => 'Downloads', 'downloads' => 'Downloads',
+13
View File
@@ -2,8 +2,21 @@
return [ return [
'no-playlist-found' => 'No Playlist found!', 'no-playlist-found' => 'No Playlist found!',
'hero-subtitle' => 'Curated episode collections made by the community.',
'by' => 'by',
'playlists-count' => 'Playlists',
'clear-search' => 'Clear search',
'personal-playlists' => 'Personal Playlists', 'personal-playlists' => 'Personal Playlists',
'create-on-personal-page' => 'You can create one in your personal playlists page.', 'create-on-personal-page' => 'You can create one in your personal playlists page.',
'play' => 'Play', 'play' => 'Play',
'playlist' => 'Playlist', 'playlist' => 'Playlist',
'search-episodes' => 'Search episodes',
'per-page' => 'Per page',
'episodes' => 'Episodes',
'empty-playlist' => 'This playlist is empty.',
'no-matches' => 'No episodes match your search.',
'watched' => 'Watched',
'remove' => 'Remove',
'now-playing' => 'Now Playing',
'remove-confirm' => 'Remove this episode from the playlist?',
]; ];
+2
View File
@@ -2,6 +2,8 @@
return [ return [
'home' => 'Accueil', 'home' => 'Accueil',
'browse' => 'Parcourir',
'community' => 'Communauté',
'search' => 'Recherche', 'search' => 'Recherche',
'public-playlists' => 'Playlists publiques', 'public-playlists' => 'Playlists publiques',
'downloads' => 'Téléchargements', 'downloads' => 'Téléchargements',
+13
View File
@@ -2,8 +2,21 @@
return [ return [
'no-playlist-found' => 'Aucune playlist n\'a été trouvée!', 'no-playlist-found' => 'Aucune playlist n\'a été trouvée!',
'hero-subtitle' => 'Collections d\'épisodes organisées par la communauté.',
'by' => 'par',
'playlists-count' => 'Playlists',
'clear-search' => 'Effacer la recherche',
'personal-playlists' => 'Playlists personnelles', 'personal-playlists' => 'Playlists personnelles',
'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.', 'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.',
'play' => 'Lire', 'play' => 'Lire',
'playlist' => 'Playlist', 'playlist' => 'Playlist',
'search-episodes' => 'Rechercher des épisodes',
'per-page' => 'Par page',
'episodes' => 'Épisodes',
'empty-playlist' => 'Cette playlist est vide.',
'no-matches' => 'Aucun épisode ne correspond à votre recherche.',
'watched' => 'Vu',
'remove' => 'Supprimer',
'now-playing' => 'En lecture',
'remove-confirm' => 'Supprimer cet épisode de la playlist ?',
]; ];
+5 -2
View File
@@ -19,12 +19,15 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="10"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<env name="DB_DATABASE" value="testing"/> <env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="hstream_testing"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="SCOUT_DRIVER" value="collection"/>
<env name="ALTCHA_HMAC_KEY" value="testing-altcha-hmac-key"/>
</php> </php>
</phpunit> </phpunit>
+4
View File
@@ -68,6 +68,10 @@ input:checked~.dot {
transform: translateX(100%); transform: translateX(100%);
} }
input:checked~.theme-icon {
transform: rotate(90deg);
}
#plyr__time_skip { #plyr__time_skip {
background: #c61e54; background: #c61e54;
border: 0; border: 0;
+66
View File
@@ -0,0 +1,66 @@
import Tagify from '@yaireo/tagify';
import '@yaireo/tagify/dist/tagify.css';
document.addEventListener('livewire:init', () => {
const Alpine = window.Alpine;
const dropdownOptions = {
classname: "color-blue",
enabled: 0,
maxItems: 10,
position: "text",
closeOnSelect: false,
highlightFirst: true
};
Alpine.data('adminReleaseTags', () => ({
tagify: null,
init() {
const el = this.$refs.tags;
window.axios.get('/admin/tags').then((response) => {
if (response.status != 200) {
return;
}
this.tagify = new Tagify(el, {
whitelist: response.data.tags,
dropdown: dropdownOptions
});
this.tagify.on('change', () => {
this.$wire.set('tags', this.tagify.value.map((t) => t.value));
});
}).catch((error) => {
console.log(error);
});
}
}));
Alpine.data('adminReleaseStudio', () => ({
tagify: null,
init() {
const el = this.$refs.studio;
window.axios.get('/admin/studios').then((response) => {
if (response.status != 200) {
return;
}
this.tagify = new Tagify(el, {
whitelist: response.data.studios,
dropdown: dropdownOptions
});
this.tagify.on('change', () => {
const values = this.tagify.value.map((t) => t.value);
this.$wire.set('studio', values.length ? values[values.length - 1] : '');
});
}).catch((error) => {
console.log(error);
});
}
}));
});
-104
View File
@@ -14,107 +14,3 @@ export function playNextPlaylistVideo() {
window.location.href = '/hentai/' + nextEpisode + '?playlist=' + playlistId; window.location.href = '/hentai/' + nextEpisode + '?playlist=' + playlistId;
} }
function deleteEntry(playlistId, episodeId) {
window.axios.post('/user/playlist-episode', {
playlist: playlistId,
episode: episodeId
}).then(function (response) {
if (response.status == 200) {
console.log(response);
if (response.data.message == 'success') {
Swal.fire({
title: "Deleted!",
text: "Removed entry from playlist!",
icon: "success",
confirmButtonText: "OK",
willClose: () => {
location.reload();
}
}).then((result) => {
if (result.isConfirmed) {
location.reload();
}
});
}
}
}).catch(function (error) {
Swal.fire({
title: "Error!",
text: error,
icon: "error"
});
console.log(error);
});
}
function addDesktopDeleteListener() {
const deleteButtons = document.querySelectorAll('[id^="delD"]');
deleteButtons.forEach(button => {
const playlist = button.id.split('-')[1];
const episode = button.id.split('-')[2];
console.log("Playlist: " + playlist + " Episode: " + episode);
button.addEventListener('click', () => deleteEntry(playlist, episode));
});
}
// Playlist Swipe (Delete)
document.addEventListener('DOMContentLoaded', () => {
const swipeContainers = document.querySelectorAll('.swipe-container');
var swipeOptions = {
dragLockToAxis: true,
dragBlockHorizontal: true
};
swipeContainers.forEach(container => {
const controls = new Hammer(container, swipeOptions);
const originalColor = container.style.backgroundColor;
const playlistId = container.id.split('-')[0];
const episodeId = container.id.split('-')[1];
const delIcon = document.getElementById('del-' + container.id);
// Set the initial position
let posX = 0;
// Listen for the pan gesture
controls.on('pan', (event) => {
// Update the X position based on the drag delta
posX = event.deltaX;
if (posX > 0) {
// Only allow left swipe
posX = 0;
}
// Apply the translation to the element
container.style.transform = `translateX(${posX}px)`;
container.style.backgroundColor = "rgba(159, 18, 18, 0.3)";
setTimeout(() => {
delIcon.classList.remove('fa-grip-lines-vertical');
delIcon.classList.add('fa-trash');
}, 300);
});
controls.on('panend', () => {
container.style.transition = 'transform 0.3s ease';
container.style.transform = 'translateX(0)';
setTimeout(() => {
container.style.transition = ''; // Reset transition for next drag
container.style.backgroundColor = originalColor;
delIcon.classList.remove('fa-trash');
delIcon.classList.add('fa-grip-lines-vertical');
}, 300);
});
controls.on('swipeleft', (event) => {
container.style.display = 'none';
console.log(playlistId, episodeId);
deleteEntry(playlistId, episodeId);
});
});
addDesktopDeleteListener();
});
+15 -12
View File
@@ -7,7 +7,7 @@ function darkModeListener() {
} }
} }
document.querySelector("input[type='checkbox']#toogleTheme")?.addEventListener("click", darkModeListener); document.querySelectorAll("input[type='checkbox']#toogleTheme").forEach((toggle) => toggle.addEventListener("click", darkModeListener));
if(localStorage.theme) { if(localStorage.theme) {
if (localStorage.theme == 'light') { if (localStorage.theme == 'light') {
@@ -15,10 +15,9 @@ if(localStorage.theme) {
document.querySelector("html").classList.toggle("dark"); document.querySelector("html").classList.toggle("dark");
} }
const toggleThemeButton = document.getElementById("toogleTheme"); document.querySelectorAll("#toogleTheme").forEach((toggle) => {
if (toggleThemeButton) { toggle.checked = true;
toggleThemeButton.checked = true; });
}
} }
} else { } else {
@@ -28,7 +27,6 @@ if(localStorage.theme) {
// Ability to disable blur effects for slower devices // Ability to disable blur effects for slower devices
const LOCAL_STORAGE_KEY = 'blur'; const LOCAL_STORAGE_KEY = 'blur';
const blurCheckbox = document.querySelector("input[type='checkbox']#toggleBlur");
function setCSSFilter(selector, value) { function setCSSFilter(selector, value) {
document.querySelectorAll(selector).forEach(el => { document.querySelectorAll(selector).forEach(el => {
@@ -38,10 +36,12 @@ function setCSSFilter(selector, value) {
function applyBlur(enabled) { function applyBlur(enabled) {
if (!enabled) { if (!enabled) {
setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg', 'none'); setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg, .backdrop-blur-xl, .backdrop-blur-2xl', 'none');
return; return;
} }
setCSSFilter('.backdrop-blur-2xl', 'blur(40px)');
setCSSFilter('.backdrop-blur-xl', 'blur(24px)');
setCSSFilter('.backdrop-blur-lg', 'blur(16px)'); setCSSFilter('.backdrop-blur-lg', 'blur(16px)');
setCSSFilter('.backdrop-blur', 'blur(8px)'); setCSSFilter('.backdrop-blur', 'blur(8px)');
setCSSFilter('.backdrop-blur-sm', 'blur(4px)'); setCSSFilter('.backdrop-blur-sm', 'blur(4px)');
@@ -51,19 +51,22 @@ function initBlurToggle() {
const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY); const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY);
const enabled = storedValue === null ? true : storedValue === 'true'; const enabled = storedValue === null ? true : storedValue === 'true';
const blurCheckboxes = document.querySelectorAll("input[type='checkbox']#toggleBlur");
// initialize UI and DOM // initialize UI and DOM
applyBlur(enabled); applyBlur(enabled);
if (blurCheckbox) blurCheckbox.checked = enabled; blurCheckboxes.forEach((checkbox) => {
checkbox.checked = enabled;
});
// add event listener // add event listener
if (blurCheckbox) { blurCheckboxes.forEach((checkbox) => {
blurCheckbox.addEventListener('click', (e) => { checkbox.addEventListener('click', (e) => {
console.log("Received Event");
const isEnabled = e.target.checked; const isEnabled = e.target.checked;
applyBlur(isEnabled); applyBlur(isEnabled);
localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false'); localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false');
}); });
} });
} }
initBlurToggle(); initBlurToggle();
-113
View File
@@ -1,113 +0,0 @@
import Tagify from '@yaireo/tagify';
import '@yaireo/tagify/dist/tagify.css';
const taginput = document.querySelector("#tags");
const studioinput = document.querySelector("#studio");
// Get Tags from API
window.axios.get('/admin/tags').then(function (response) {
if (response.status != 200) {
return;
}
new Tagify(taginput, {
whitelist: response.data.tags,
dropdown: {
classname: "color-blue",
enabled: 0, // show the dropdown immediately on focus
maxItems: 10,
position: "text", // place the dropdown near the typed text
closeOnSelect: false, // keep the dropdown open after selecting a suggestion
highlightFirst: true
}
});
}).catch(function (error) {
console.log(error);
});
// Get Studios from API
window.axios.get('/admin/studios').then(function (response) {
if (response.status != 200) {
return;
}
new Tagify(studioinput, {
whitelist: response.data.studios,
dropdown: {
classname: "color-blue",
enabled: 0, // show the dropdown immediately on focus
maxItems: 10,
position: "text", // place the dropdown near the typed text
closeOnSelect: false, // keep the dropdown open after selecting a suggestion
highlightFirst: true
}
});
}).catch(function (error) {
console.log(error);
});
let eps = 1;
function dynEpisode() {
let amount = this.value;
if (amount > eps) {
eps += 1;
var episodeUploads = `
<div class="grid grid-cols-2" id="dynU` + eps + `">
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover` + eps + `">Cover ` + eps + `:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodecover` + eps + `" id="episodecover` + eps + `" required>
</div>
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery` + eps + `">Gallery ` + eps + `:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodegallery` + eps + `[]" id="episodegallery` + eps + `" multiple="">
</div>
</div>
<div class="p-4 pt-0" id="dynB` + eps + `">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description` + eps + `">Description ` + eps + `:</label>
<textarea rows="4" cols="50" id="description` + eps + `" name="description` + eps + `" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required>
</textarea>
</div>
<div class="p-4 pt-0" id="dynD` + eps + `">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl` + eps + `">Download 1080p ` + eps + `:</label>
<input class="border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm block w-full" id="episodedlurl` + eps + `" type="text" name="episodedlurl` + eps + `" required="required">
</div>
<div class="p-4 pt-0" id="dynD48fps` + eps + `">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated` + eps + `">Download 1080p48fps ` + eps + `:</label>
<input class="border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm block w-full" id="episodedlurlinterpolated` + eps + `" type="text" name="episodedlurlinterpolated` + eps + `">
</div>
<div class="p-4 pt-0" id="dynD4k` + eps + `">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k` + eps + `">Download 4k ` + eps + `:</label>
<input class="border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm block w-full" id="episodedlurl4k` + eps + `" type="text" name="episodedlurl4k` + eps + `" required="required">
</div>
<div class="p-4 pt-0" id="dynDUHD48fps` + eps + `">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi` + eps + `">Download 4k 48fps ` + eps + `:</label>
<input class="border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm block w-full" id="downloadUHDi` + eps + `" type="text" name="downloadUHDi` + eps + `">
</div>
`;
var element = document.getElementById('moreEpisodes');
element.innerHTML = element.innerHTML + episodeUploads;
} else if (amount < eps) {
if (amount == 0) {
this.value = 1;
return;
}
document.getElementById("dynU" + eps).remove();
document.getElementById("dynD" + eps).remove();
document.getElementById("dynD4k" + eps).remove();
document.getElementById("dynD48fps" + eps).remove();
document.getElementById("dynDUHD48fps" + eps).remove();
document.getElementById("dynB" + eps).remove();
eps -= 1;
}
}
document.getElementById("episodes").addEventListener('change', dynEpisode);
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900"> <div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
@include('layouts.navigation') @include('layouts.navigation')
@include('partials.background') @include('partials.background')
<div class="mt-[65px]"> <div class="mt-[80px]">
@include('admin.partials.sidenav') @include('admin.partials.sidenav')
<div class="pl-64"> <div class="pl-64">
@yield('content') @yield('content')
@@ -6,76 +6,8 @@
<!--Modal body--> <!--Modal body-->
<div class="relative p-4 pt-0"> <div class="relative p-4 pt-0">
<form method="POST" action="{{ route('admin.upload.episode') }}" enctype="multipart/form-data"> @livewire('admin-episode-form', ['episodeId' => $episode->id])
@csrf
<input name="episode_id" id="episode_id" type="hidden"/>
<!-- Episodes -->
<div class="grid grid-cols-2">
<!-- Cover -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover1">Cover:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodecover1" id="episodecover1" required>
</div>
<!-- Thumbs -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery1">Gallery:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodegallery1[]" id="episodegallery1" multiple="">
</div>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description">Description:</label>
<textarea rows="4" cols="50" id="description" name="description" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required>{{ $episode->description }}</textarea>
</div>
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="baseurl">Stream:</label>
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" required />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p:</label>
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" required />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated1">Download 1080p 48fps:</label>
<x-text-input id="episodedlurlinterpolated1" class="block w-full" type="text" name="episodedlurlinterpolated1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k:</label>
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi1">Download 4k 48fps:</label>
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" />
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel
</button>
<button type="submit" class="ml-1 inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
Add
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
<!-- Modals JS -->
<script>
document.getElementById('episode_id').value = document.getElementById('e_id').value;
</script>
</div> </div>
+3 -107
View File
@@ -1,111 +1,7 @@
@extends('admin.layout') @extends('admin.layout')
@section('content') @section('content')
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"> @livewire('admin-release-form')
<div class="flex items-center justify-center">
<div class="relative p-4 pt-0 bg-white dark:bg-neutral-800 rounded-lg border-t-2 border-b-2 border-pink-700">
<form method="POST" action="{{ route('admin.upload') }}" enctype="multipart/form-data">
@csrf
<div class="grid grid-cols-3">
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title">Title:</label>
<x-text-input id="title" class="block w-full" type="text" name="title" required autofocus/>
</div>
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title_jpn">Title JPN:</label>
<x-text-input id="title_jpn" class="block w-full" type="text" name="title_jpn" required />
</div>
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="studio">Studio:</label>
<x-text-input id="studio" class="block w-full" type="text" name="studio" required />
</div>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="tags">Tags:</label>
<x-text-input id="tags" class="block w-full" type="text" name="tags" required />
</div>
<div class="grid grid-cols-2">
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="releasedate">Release Date:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="date" name="releasedate" id="releasedate" required>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodes">Episodes:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="number" name="episodes" id="episodes" value="1" required>
</div>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="baseurl">Stream:</label>
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" required />
</div>
<!-- Episodes -->
<div class="grid grid-cols-2">
<!-- Cover -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover1">Cover 1:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodecover1" id="episodecover1" required>
</div>
<!-- Thumbs -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery1">Gallery 1:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodegallery1[]" id="episodegallery1" multiple="">
</div>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description1">Description 1:</label>
<textarea rows="4" cols="50" id="description1" name="description1" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required></textarea>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p 1:</label>
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated1">Download 1080p48fps 1:</label>
<x-text-input id="episodedlurlinterpolated1" class="block w-full" type="text" name="episodedlurlinterpolated1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k 1:</label>
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi1">Download 4k 48fps 1:</label>
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" />
</div>
<div id="moreEpisodes">
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel
</button>
<button type="submit" class="ml-1 inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
Create
</button>
</div>
</form>
</div>
</div>
</div>
@vite(['resources/js/upload.js'])
@endsection @endsection
@vite(['resources/js/admin-release.js'])
@@ -1 +1 @@
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a> <a {{ $attributes->merge(['class' => 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-start text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-rose-50/70 dark:hover:bg-rose-950/30 hover:text-rose-700 dark:hover:text-rose-300 focus:outline-none transition-colors duration-150']) }}>{{ $slot }}</a>
@@ -1,4 +1,4 @@
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-neutral-800']) @props(['align' => 'right', 'width' => '48', 'contentClasses' => 'p-2 rounded-2xl border border-gray-200/70 dark:border-white/10 bg-white/95 dark:bg-neutral-900/80 backdrop-blur-2xl shadow-2xl shadow-black/10 dark:shadow-black/50'])
@php @php
$alignmentClasses = match ($align) { $alignmentClasses = match ($align) {
@@ -9,6 +9,9 @@ $alignmentClasses = match ($align) {
$width = match ($width) { $width = match ($width) {
'48' => 'w-48', '48' => 'w-48',
'56' => 'w-56',
'64' => 'w-64',
'72' => 'w-72',
default => $width, default => $width,
}; };
@endphp @endphp
@@ -25,10 +28,10 @@ $width = match ($width) {
x-transition:leave="transition ease-in duration-75" x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}" class="absolute z-50 mt-2 {{ $width }} rounded-2xl shadow-lg {{ $alignmentClasses }}"
style="display: none;" style="display: none;"
@click="open = false"> @click="open = false">
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}"> <div class="rounded-2xl ring-1 ring-black/5 {{ $contentClasses }}">
{{ $content }} {{ $content }}
</div> </div>
</div> </div>
@@ -2,8 +2,8 @@
@php @php
$classes = ($active ?? false) $classes = ($active ?? false)
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' ? 'inline-flex items-center gap-1.5 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 px-3.5 py-2 text-sm font-semibold text-white shadow-md shadow-rose-600/25'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out'; : 'inline-flex items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-rose-50 dark:hover:bg-rose-950/30 hover:text-rose-700 dark:hover:text-rose-300 transition-colors';
@endphp @endphp
<a {{ $attributes->merge(['class' => $classes]) }}> <a {{ $attributes->merge(['class' => $classes]) }}>
@@ -2,8 +2,8 @@
@php @php
$classes = ($active ?? false) $classes = ($active ?? false)
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out' ? 'flex w-full items-center gap-3 rounded-xl border-l-4 border-rose-500 bg-rose-50/80 dark:bg-rose-950/30 px-4 py-2.5 text-sm font-semibold text-rose-700 dark:text-rose-300'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 focus:outline-none focus:text-neutral-800 dark:focus:text-neutral-200 focus:bg-neutral-50 dark:focus:bg-neutral-700 focus:border-neutral-300 dark:focus:border-neutral-600 transition duration-150 ease-in-out'; : 'flex w-full items-center gap-3 rounded-xl border-l-4 border-transparent px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-rose-50/60 dark:hover:bg-rose-950/20 hover:text-rose-700 dark:hover:text-rose-300 transition-colors';
@endphp @endphp
<a {{ $attributes->merge(['class' => $classes]) }}> <a {{ $attributes->merge(['class' => $classes]) }}>
+2 -2
View File
@@ -9,7 +9,7 @@
<!-- Page Heading --> <!-- Page Heading -->
@if (isset($header)) @if (isset($header))
<header class="bg-white dark:bg-neutral-950/50 backdrop-blur-lg shadow mt-[65px] z-10"> <header class="bg-white dark:bg-neutral-950/50 backdrop-blur-lg shadow mt-[80px] z-10">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }} {{ $header }}
</div> </div>
@@ -17,7 +17,7 @@
@endif @endif
<!-- Page Content --> <!-- Page Content -->
<div @if (!isset($header)) class="mt-[65px]" @else class="mt-[40px]" @endif> <div @if (!isset($header)) class="mt-[80px]" @else class="mt-[40px]" @endif>
<main> <main>
{{ $slot }} {{ $slot }}
</main> </main>
+284 -218
View File
@@ -1,152 +1,134 @@
<nav x-data="{ open: false }" <nav x-data="{ open: false }" class="fixed inset-x-0 top-0 z-50">
class="bg-white/30 dark:bg-neutral-950/40 border-b border-gray-200 dark:border-neutral-700 fixed xs:absolute sm:fixed w-[100%] z-50 backdrop-blur"> @php $notAvailable = auth()->check() ? auth()->user()->unreadNotifications()->count() > 0 : false; @endphp
<!-- Primary Navigation Menu -->
<div class="max-w-[100%] xl:max-w-[95%] 2xl:max-w-[84%] mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-700 border-neutral-300/50 dark:text-gray-200 bg-white/20 dark:bg-neutral-950/20 dark:border-neutral-800/50 hover:text-gray-800 dark:hover:text-gray-100 focus:outline-none transition ease-in-out duration-150">
<div class="shrink-0 flex items-center"> <div class="mx-auto max-w-[100%] px-3 pt-3 sm:px-5 xl:max-w-[95%] 2xl:max-w-[84%]">
<img src="/images/cropped-HS-1-192x192.webp" class="h-8 mr-3" alt="hstream.moe Logo" /> <div class="flex h-16 items-center justify-between gap-3 rounded-2xl border border-gray-200/60 bg-white/70 px-3 backdrop-blur-xl shadow-lg shadow-black/5 dark:border-white/10 dark:bg-neutral-950/60 dark:shadow-black/40 sm:px-5">
<span class="self-center text-2xl font-semibold whitespace-nowrap">hstream.moe</span> {{-- Brand --}}
</div> <div class="flex shrink-0 items-center">
<a href="{{ route('home.index') }}" class="flex items-center gap-2.5">
<div class="ml-1"> <span class="relative">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" <img src="/images/cropped-HS-1-192x192.webp" class="h-9 w-9 rounded-xl " alt="hstream.moe Logo" />
viewBox="0 0 20 20"> </span>
<path fill-rule="evenodd" <span class="hidden text-lg font-bold bg-gradient-to-r from-rose-600 to-pink-500 bg-clip-text text-transparent sm:block">hstream.moe</span>
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" </a>
clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('home.index')">
<i class="fa-solid fa-house"></i> {{ __('nav.home') }}
</x-dropdown-link>
<x-dropdown-link :href="route('hentai.search')">
<i class="fa-solid fa-magnifying-glass"></i> {{ __('nav.search') }}
</x-dropdown-link>
<x-dropdown-link :href="route('playlist.index')">
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.public-playlists') }}
</x-dropdown-link>
@auth
<x-dropdown-link :href="route('download.search')">
<i class="fa-solid fa-download"></i> {{ __('nav.downloads') }}
</x-dropdown-link>
@endauth
<x-dropdown-link :href="config('discord.invite_link')">
<i class="fa-brands fa-discord"></i> {{ __('nav.our-discord-server') }}
</x-dropdown-link>
<x-dropdown-link :href="route('join.matrix')">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-matrix pr-1">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 3h-1v18h1" />
<path d="M20 21h1v-18h-1" />
<path d="M7 9v6" />
<path d="M12 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
<path d="M17 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
</svg>
Join our Matrix
</div>
</x-dropdown-link>
<x-dropdown-link>
<div class="grid grid-cols-2">
<p class="cursor-default">{{ __('nav.theme') }}</p>
<div class="flex items-center">
<div class="absolute right-6">
@include('partials.themeswitcher')
</div>
</div>
</div>
</x-dropdown-link>
{{-- Expiremental --}}
<x-dropdown-link>
@include('partials.blurswitcher')
</x-dropdown-link>
</x-slot>
</x-dropdown>
</div> </div>
<div class="items-center hidden md:flex"> {{-- Center: inline links + live search --}}
@livewire('nav-live-search') <div class="flex items-center gap-1">
<div class="hidden lg:block pl-4"> <div class="hidden items-center gap-1 lg:flex">
<div class="flex flex-col items-center bg-gray-50/20 dark:bg-neutral-900/40 rounded-md"> <x-nav-link :href="route('home.index')" :active="request()->routeIs('home.index')">
<a href="{{ route('hentai.random') }}" <i class="fa-solid fa-house"></i> {{ __('nav.home') }}
class="cursor-pointer px-4 py-2 text-sm font-medium dark:hover:text-white text-gray-500 dark:text-white/90 focus:outline-none flex flex-col items-center md:flex-row"> </x-nav-link>
<i class="fa-solid fa-shuffle"></i>
<p class="md:pl-1 pl-0">Random</p> <x-nav-link :href="route('hentai.search')" :active="request()->routeIs('hentai.search', 'hentai.searchredirect')">
</a> <i class="fa-solid fa-compass"></i> {{ __('nav.browse') }}
</div> </x-nav-link>
<x-nav-link :href="route('playlist.index')" :active="request()->routeIs('playlist.*')">
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.public-playlists') }}
</x-nav-link>
@auth
<x-nav-link :href="route('download.search')" :active="request()->routeIs('download.search')">
<i class="fa-solid fa-download"></i> {{ __('nav.downloads') }}
</x-nav-link>
@endauth
</div>
<div class="hidden items-center md:flex">
@livewire('nav-live-search')
</div> </div>
</div> </div>
@auth {{-- Right cluster --}}
@php $notAvailable = Auth::user()->unreadNotifications()->count() > 0; @endphp <div class="hidden items-center gap-1.5 md:flex">
@else {{-- Community dropdown --}}
@php $notAvailable = false; @endphp <x-dropdown align="right" width="56">
@endauth
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ml-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger"> <x-slot name="trigger">
<button <button type="button" title="{{ __('nav.community') }}"
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-500 border-neutral-300/50 dark:text-gray-400 bg-white/20 dark:bg-neutral-950/20 dark:border-neutral-800/50 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150"> class="inline-flex h-9 w-9 items-center justify-center rounded-full ring-1 ring-gray-200 text-gray-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:ring-neutral-700 dark:text-gray-300 dark:hover:bg-rose-950/40 dark:hover:text-rose-300">
<i class="fa-solid fa-users"></i>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="config('discord.invite_link')">
<i class="fa-brands fa-discord text-indigo-500 dark:text-indigo-400"></i> {{ __('nav.our-discord-server') }}
</x-dropdown-link>
<x-dropdown-link :href="route('join.matrix')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-matrix shrink-0">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 3h-1v18h1" />
<path d="M20 21h1v-18h-1" />
<path d="M7 9v6" />
<path d="M12 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
<path d="M17 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
</svg>
Join our Matrix
</x-dropdown-link>
<x-dropdown-link :href="route('contact.index')">
<i class="fa-solid fa-message"></i> Contact
</x-dropdown-link>
</x-slot>
</x-dropdown>
{{-- Random --}}
<a href="{{ route('hentai.random') }}" title="Random"
class="inline-flex h-9 w-9 items-center justify-center rounded-full ring-1 ring-gray-200 text-gray-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:ring-neutral-700 dark:text-gray-300 dark:hover:bg-rose-950/40 dark:hover:text-rose-300">
<i class="fa-solid fa-shuffle"></i>
</a>
{{-- Theme toggle --}}
@include('partials.themeswitcher')
{{-- Account dropdown --}}
<x-dropdown align="right" width="72">
<x-slot name="trigger">
<button type="button"
class="inline-flex items-center gap-2 rounded-full py-1 pl-1 pr-2 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
@auth @auth
<img class="h-8 w-8 rounded-full object-cover mr-2" <img class="h-9 w-9 rounded-full object-cover ring-2 ring-rose-500/50"
src="{{ Auth::user()->getAvatar() }}" src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" /> alt="{{ Auth::user()->name }}" />
@else <span class="hidden items-center gap-1.5 text-sm font-semibold text-gray-800 dark:text-neutral-200 xl:inline-flex">
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
alt="Guest" />
@endauth
@auth
<div style="display: flex; flex-direction: row; align-items: flex-start;">
{{ Auth::user()->name }} {{ Auth::user()->name }}
@if ($notAvailable) @if ($notAvailable)
<i class="fa-solid fa-bell text-rose-600"></i> <span class="relative">
<i class="fa-solid fa-bell text-rose-600"></i>
<span class="absolute -top-1 -right-1 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
</span>
@endif @endif
</div> </span>
@else @else
<div style="display: flex; flex-direction: column; align-items: flex-start;"> <img class="h-9 w-9 rounded-full object-cover ring-2 ring-rose-500/50" src="/images/default-avatar.webp"
Guest alt="Guest" />
<small>{{ __('nav.please-login') }}</small> <span class="hidden flex-col items-start leading-tight xl:flex">
</div> <span class="text-sm font-semibold text-gray-800 dark:text-neutral-200">Guest</span>
<small class="text-xs text-gray-400 dark:text-gray-500">{{ __('nav.please-login') }}</small>
</span>
@endauth @endauth
<div class="ml-1"> <svg class="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" <path fill-rule="evenodd"
viewBox="0 0 20 20"> d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
<path fill-rule="evenodd" clip-rule="evenodd" />
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" </svg>
clip-rule="evenodd" />
</svg>
</div>
</button> </button>
</x-slot> </x-slot>
<x-slot name="content"> <x-slot name="content">
@auth @auth
<p class="px-3 pb-1 pt-1.5 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<x-dropdown-link :href="route('profile.show')"> <x-dropdown-link :href="route('profile.show')">
<i class="fa-solid fa-user"></i> {{ __('Profile') }} <i class="fa-solid fa-user"></i> {{ __('nav.profile') }}
</x-dropdown-link> </x-dropdown-link>
@if ($notAvailable) @if ($notAvailable)
@@ -175,6 +157,10 @@
<i class="fa-solid fa-eye"></i> {{ __('nav.watched') }} <i class="fa-solid fa-eye"></i> {{ __('nav.watched') }}
</x-dropdown-link> </x-dropdown-link>
<div class="my-1 border-t border-gray-100 dark:border-white/5"></div>
<p class="px-3 pb-1 pt-1.5 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Preferences</p>
<x-dropdown-link :href="route('profile.settings')"> <x-dropdown-link :href="route('profile.settings')">
<i class="fa-solid fa-gear"></i> {{ __('nav.settings') }} <i class="fa-solid fa-gear"></i> {{ __('nav.settings') }}
</x-dropdown-link> </x-dropdown-link>
@@ -184,10 +170,11 @@
<i class="fa-solid fa-user-tie"></i> Admin <i class="fa-solid fa-user-tie"></i> Admin
</x-dropdown-link> </x-dropdown-link>
@endif @endif
@endauth
<!-- Authentication --> @include('partials.blurswitcher')
@auth
<div class="my-1 border-t border-gray-100 dark:border-white/5"></div>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
@@ -202,7 +189,7 @@
@guest @guest
<x-dropdown-link :href="route('login')"> <x-dropdown-link :href="route('login')">
<div <div
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]"> class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 px-4 py-2.5 font-semibold text-white transition-colors hover:from-rose-500 hover:to-pink-500">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }} <i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</div> </div>
</x-dropdown-link> </x-dropdown-link>
@@ -211,11 +198,11 @@
</x-dropdown> </x-dropdown>
</div> </div>
<!-- Hamburger --> {{-- Hamburger --}}
<div class="-mr-2 flex items-center sm:hidden"> <div class="flex items-center md:hidden">
<button @click="open = ! open" <button @click="open = ! open" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-neutral-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out"> class="relative inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-500 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-gray-400 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24"> <svg class="h-5 w-5" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex" <path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" /> d="M4 6h16M4 12h16M4 18h16" />
@@ -223,10 +210,9 @@
stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@if ($notAvailable) @if ($notAvailable)
<span class="absolute mb-4 ml-4 flex h-3 w-3 float-right"> <span class="absolute -right-0.5 -top-0.5 flex h-3 w-3">
<span <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span> <span class="relative inline-flex h-3 w-3 rounded-full bg-rose-500"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span> </span>
@endif @endif
</button> </button>
@@ -234,90 +220,170 @@
</div> </div>
</div> </div>
<!-- Responsive Navigation Menu --> {{-- Drawer overlay --}}
@auth <div x-show="open" @click="open = false"
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden"> class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm md:hidden"
<div class="pt-2 pb-3 space-y-1"> style="display: none"></div>
@include('partials.mobilesearch')
</div>
<!-- Responsive Settings Options --> {{-- Slide-in drawer --}}
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600 dark:bg-neutral-900/30"> <div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-x-full"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-80 max-w-[85%] flex-col overflow-y-auto border-l border-gray-200/70 bg-white/95 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/90 md:hidden"
style="display: none">
<div class="flex justify-center"> {{-- Drawer header --}}
<img class="h-8 w-8 rounded-full object-cover mr-2" <div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
@auth
<img class="h-10 w-10 rounded-full object-cover ring-2 ring-rose-500/50"
src="{{ Auth::user()->getAvatar() }}" src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" /> alt="{{ Auth::user()->name }}" />
<span class="font-medium text-base text-gray-800 dark:text-neutral-200"> <span class="truncate text-sm font-semibold text-gray-800 dark:text-neutral-200">{{ Auth::user()->name }}</span>
{{ Auth::user()->name }} @else
</span> <img class="h-10 w-10 rounded-full object-cover ring-2 ring-rose-500/50" src="/images/default-avatar.webp"
</div> alt="Guest" />
<span class="truncate text-sm font-semibold text-gray-800 dark:text-neutral-200">Guest</span>
@endauth
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.show')" :active="request()->routeIs('user.show')">
<i class="fa-solid fa-user pr-4"></i> {{ __('nav.profile') }}
</x-responsive-nav-link>
@if ($notAvailable)
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell pr-4"></i> Notifications
<span class="relative flex h-3 w-3 float-right top-[6px]">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
</x-responsive-nav-link>
@else
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell pr-4"></i> Notifications
</x-responsive-nav-link>
@endif
<x-responsive-nav-link :href="route('profile.comments')" :active="request()->routeIs('profile.comments')">
<i class="fa-solid fa-comment pr-4"></i> {{ __('nav.comments') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.likes')" :active="request()->routeIs('profile.likes')">
<i class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.playlists')" :active="request()->routeIs('profile.playlists')">
<i class="fa-solid fa-rectangle-list pr-4"></i> {{ __('nav.playlists') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('user.watched')" :active="request()->routeIs('user.watched')">
<i class="fa-solid fa-eye pr-4"></i> {{ __('nav.watched') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
<i class="fa-solid fa-right-from-bracket pr-4"></i> {{ __('nav.logout') }}
</x-responsive-nav-link>
</form>
</div>
</div> </div>
<button @click="open = false" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-500 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-gray-400 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
@else
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
<!-- Search Form Mobile -->
<div class="pt-2 pb-3 space-y-1">
@include('partials.mobilesearch')
<div class="pb-1 text-center w-full"> {{-- Primary links --}}
<x-responsive-nav-link :href="route('login')"> <p class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Menu</p>
<div <div class="space-y-1">
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]"> <x-responsive-nav-link :href="route('home.index')" :active="request()->routeIs('home.index')">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }} <i class="fa-solid fa-house"></i> {{ __('nav.home') }}
</div> </x-responsive-nav-link>
</x-responsive-nav-link>
</div> <x-responsive-nav-link :href="route('hentai.search')" :active="request()->routeIs('hentai.search', 'hentai.searchredirect')">
</div> <i class="fa-solid fa-compass"></i> {{ __('nav.browse') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('playlist.index')" :active="request()->routeIs('playlist.*')">
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.public-playlists') }}
</x-responsive-nav-link>
@auth
<x-responsive-nav-link :href="route('download.search')" :active="request()->routeIs('download.search')">
<i class="fa-solid fa-download"></i> {{ __('nav.downloads') }}
</x-responsive-nav-link>
@endauth
</div> </div>
@endauth
{{-- Mobile search --}}
<div class="pt-4">
@include('partials.mobilesearch')
</div>
@auth
{{-- Account --}}
<p class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<div class="space-y-1">
@if ($notAvailable)
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell"></i> Notifications
<span class="relative ml-auto flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-rose-500"></span>
</span>
</x-responsive-nav-link>
@else
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell"></i> Notifications
</x-responsive-nav-link>
@endif
<x-responsive-nav-link :href="route('profile.show')" :active="request()->routeIs('profile.show')">
<i class="fa-solid fa-user"></i> {{ __('nav.profile') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.comments')" :active="request()->routeIs('profile.comments')">
<i class="fa-solid fa-comment"></i> {{ __('nav.comments') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.likes')" :active="request()->routeIs('profile.likes')">
<i class="fa-solid fa-heart"></i> {{ __('nav.likes') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.playlists')" :active="request()->routeIs('profile.playlists')">
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.playlists') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('user.watched')" :active="request()->routeIs('user.watched')">
<i class="fa-solid fa-eye"></i> {{ __('nav.watched') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.settings')" :active="request()->routeIs('profile.settings')">
<i class="fa-solid fa-gear"></i> {{ __('nav.settings') }}
</x-responsive-nav-link>
@if (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<x-responsive-nav-link href="{{ route('admin.upload.index') }}">
<i class="fa-solid fa-user-tie"></i> Admin
</x-responsive-nav-link>
@endif
</div>
{{-- Logout --}}
<form method="POST" action="{{ route('logout') }}" class="pt-4">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
<i class="fa-solid fa-right-from-bracket"></i> {{ __('nav.logout') }}
</x-responsive-nav-link>
</form>
@else
{{-- Guest login CTA --}}
<div class="pt-4">
<a href="{{ route('login') }}"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 px-4 py-2.5 font-semibold text-white transition-colors hover:from-rose-500 hover:to-pink-500">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</a>
</div>
@endauth
{{-- Community --}}
<p class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">{{ __('nav.community') }}</p>
<div class="space-y-1">
<x-responsive-nav-link :href="config('discord.invite_link')">
<i class="fa-brands fa-discord text-indigo-500 dark:text-indigo-400"></i> {{ __('nav.our-discord-server') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('join.matrix')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-matrix shrink-0">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 3h-1v18h1" />
<path d="M20 21h1v-18h-1" />
<path d="M7 9v6" />
<path d="M12 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
<path d="M17 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
</svg>
Join our Matrix
</x-responsive-nav-link>
</div>
{{-- Toggles --}}
<div class="mt-4 space-y-2 rounded-2xl border border-gray-200/70 p-2 dark:border-white/10">
<div class="flex items-center justify-between gap-3 px-3 py-1.5">
<p class="cursor-default text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('nav.theme') }}</p>
@include('partials.themeswitcher')
</div>
@include('partials.blurswitcher')
</div>
</div>
</nav> </nav>
@@ -0,0 +1,185 @@
<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>
@@ -0,0 +1,234 @@
<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>
@@ -3,7 +3,7 @@
@csrf @csrf
<div class="relative group"> <div class="relative group">
<label for="live-search" class="sr-only">Search</label> <label for="live-search" class="sr-only">Search</label>
<div class="relative w-full sm:min-w-[200px] md:min-w-[300px] lg:min-w-[400px] xl:min-w-[500px]"> <div class="relative w-full sm:min-w-[200px] md:min-w-[200px] lg:min-w-[240px] xl:min-w-[300px]">
{{-- Search Icon --}} {{-- Search Icon --}}
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center"> <div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
@@ -17,7 +17,7 @@
type="search" type="search"
id="live-search" id="live-search"
name="live-search" name="live-search"
class="block w-full pl-10 pr-28 py-3 text-sm rounded-2xl border border-gray-200 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 placeholder-gray-400 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition" class="block w-full pl-10 pr-24 py-3 text-sm rounded-2xl border border-gray-200 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 placeholder-gray-400 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
placeholder="@if(request()->path() !== 'search'){{ __('search.search-hentai') }}@endif" placeholder="@if(request()->path() !== 'search'){{ __('search.search-hentai') }}@endif"
required required
@if(request()->path() == 'search') disabled @endif @if(request()->path() == 'search') disabled @endif
@@ -0,0 +1,73 @@
@props([
'playlistEpisode',
'isOwner' => false,
])
@php
$episode = $playlistEpisode->episode;
@endphp
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}"
class="group flex items-center gap-3 sm:gap-4 rounded-xl border border-neutral-200/70 bg-white p-2.5 sm:p-3 shadow-sm transition hover:border-rose-400/40 hover:bg-rose-50 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-900">
<div
class="h-8 w-8 shrink-0 rounded-full bg-neutral-100 text-sm font-bold text-neutral-500 flex items-center justify-center dark:bg-neutral-800 dark:text-neutral-400">
{{ $playlistEpisode->position }}
</div>
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}" class="shrink-0">
<img src="{{ $episode->gallery->first()?->thumbnail_url }}"
alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="160"
class="aspect-video w-24 sm:w-36 rounded-lg object-cover transition-transform duration-300 group-hover:scale-105">
</a>
<div class="min-w-0 flex-1">
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
class="block truncate font-bold text-neutral-900 transition hover:text-rose-500 dark:text-white dark:hover:text-rose-400">
{{ $episode->title }} - {{ $episode->episode }}
</a>
@if ($episode->title_jpn)
<p class="truncate text-sm text-neutral-500 dark:text-neutral-400">{{ $episode->title_jpn }}</p>
@endif
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] font-semibold">
<span class="rounded-full bg-black/70 px-2 py-0.5 text-white ring-1 ring-white/10 dark:bg-neutral-700">
{{ $episode->getResolution() }}
</span>
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }}
</span>
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
<i class="fa-regular fa-heart"></i> {{ $episode->likeCount() }}
</span>
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
<i class="fa-regular fa-comment"></i> {{ $episode->commentCount() }}
</span>
@auth
@if ($episode->userWatched(auth()->id()))
<span
class="flex items-center gap-1 rounded-full bg-emerald-800/40 px-2 py-0.5 text-emerald-300 ring-1 ring-emerald-500/30">
<i class="fa-solid fa-eye"></i> {{ __('playlist.watched') }}
</span>
@endif
@endauth
</div>
</div>
@if ($isOwner)
<div class="flex shrink-0 items-center gap-1">
<button type="button" wire:click="moveUp({{ $playlistEpisode->id }})"
class="h-8 w-8 rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button type="button" wire:click="moveDown({{ $playlistEpisode->id }})"
class="h-8 w-8 rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
<i class="fa-solid fa-arrow-down"></i>
</button>
<button type="button" wire:click="remove({{ $playlistEpisode->id }})"
class="h-8 w-8 rounded-lg text-red-500 transition hover:bg-red-50 dark:hover:bg-red-950/40">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
@endif
</div>
@@ -0,0 +1,58 @@
@props([
'playlistEpisode',
'isActive' => false,
'isOwner' => false,
])
@php
$episode = $playlistEpisode->episode;
@endphp
<div
wire:key="playlist-sidebar-row-{{ $playlistEpisode->id }}"
@if ($isActive) data-active-row aria-current="true" @endif
class="group relative flex h-20 shrink-0 items-center gap-3 px-2.5 transition {{ $isActive ? 'bg-rose-600/10 dark:bg-rose-500/10' : 'hover:bg-neutral-100 dark:hover:bg-neutral-900' }}"
>
@if ($isActive)
<span class="absolute inset-y-0 left-0 w-1 bg-rose-600"></span>
@endif
<div class="flex h-7 w-7 shrink-0 items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
@if ($isActive)
<i class="fa-solid fa-play text-rose-600 dark:text-rose-400"></i>
@else
{{ $playlistEpisode->position }}
@endif
</div>
<a href="{{ route('hentai.index', ['title' => $episode->slug, 'playlist' => $playlistEpisode->playlist_id]) }}" class="shrink-0">
<img
src="{{ $episode->gallery->first()?->thumbnail_url }}"
alt="{{ $episode->title }} - {{ $episode->episode }}"
loading="{{ $isActive ? 'eager' : 'lazy' }}"
decoding="async"
width="100"
class="h-14 w-[100px] rounded-md object-cover {{ $isActive ? 'ring-2 ring-rose-600/60' : '' }}"
>
</a>
<div class="min-w-0 flex-1">
<a href="{{ route('hentai.index', ['title' => $episode->slug, 'playlist' => $playlistEpisode->playlist_id]) }}"
class="block truncate text-sm font-semibold text-neutral-900 transition hover:text-rose-500 dark:text-white dark:hover:text-rose-400">
{{ $episode->title }}
</a>
<p class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{ $episode->studio->name }}</p>
</div>
@if ($isOwner)
<button
type="button"
wire:click="remove({{ $playlistEpisode->id }})"
wire:confirm="{{ __('playlist.remove-confirm') }}"
aria-label="{{ __('playlist.remove') }}"
class="shrink-0 rounded-md p-2 text-neutral-400 transition hover:bg-red-50 hover:text-red-600 sm:opacity-0 sm:group-hover:opacity-100 dark:text-neutral-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
>
<i class="fa-solid fa-trash-can"></i>
</button>
@endif
</div>
@@ -0,0 +1,41 @@
@props(['status' => null])
@php
$state = is_array($status) ? ($status['state'] ?? 'idle') : 'idle';
$message = is_array($status) ? ($status['message'] ?? '') : '';
$mirrors = is_array($status) ? ($status['mirrors'] ?? []) : [];
$missing = [];
foreach ($mirrors as $domain => $paths) {
foreach ((array) $paths as $ok) {
if (! $ok) {
$missing[] = preg_replace('#^https?://#', '', $domain);
break;
}
}
}
@endphp
@if ($state === 'checking')
<div class="flex items-center gap-2 text-xs text-blue-500">
<svg class="w-3.5 h-3.5 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>{{ $message }}</span>
</div>
@elseif ($state === 'valid')
<div class="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span>{{ $message }}</span>
</div>
@elseif ($state === 'invalid')
<div class="flex items-center gap-2 text-xs text-red-500">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
<div>
<span>{{ $message }}</span>
@if (! empty($missing))
<div class="mt-0.5 text-[10px] text-red-400">Missing: {{ implode(', ', $missing) }}</div>
@endif
</div>
</div>
@endif
@@ -1,57 +1,58 @@
<div> <div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage"
class="text-gray-900 dark:text-white">
<div <div
class="relative pt-5 mx-auto sm:px-6 lg:px-8 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"> class="relative pt-5 mx-auto sm:px-6 lg:px-8 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
<!-- Header --> <!-- Header -->
<div class="flex text-sm font-light bg-neutral-950/50 backdrop-blur-lg rounded-lg p-10 gap-2"> <div
<div> class="flex flex-col items-center gap-6 rounded-2xl border border-neutral-200/70 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:gap-8 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950">
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg" <img class="h-24 w-24 shrink-0 rounded-full shadow-lg ring-2 ring-rose-500/20"
src="{{ $playlist->user->getAvatar() }}"> src="{{ $playlist->user->getAvatar() }}">
</div>
<div class="flex flex-col justify-center flex-1 pl-4"> <div class="flex min-w-0 flex-1 flex-col text-center sm:text-left">
@if ($editingName) @if ($editingName)
<div class="flex items-center gap-2 mb-1"> <div class="flex flex-wrap items-center justify-center gap-2 mb-1 sm:justify-start">
<input <input
type="text" type="text"
wire:model="editingPlaylistName" wire:model="editingPlaylistName"
maxlength="30" maxlength="30"
class="rounded-lg border border-neutral-400 bg-neutral-800 px-3 py-1.5 text-xl font-bold text-white focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500" class="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-xl font-bold text-neutral-900 focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
/> />
<button wire:click="updateName" class="rounded-lg bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-700"> <button wire:click="updateName" class="rounded-lg bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-700">
Save Save
</button> </button>
<button wire:click="cancelEditName" class="rounded-lg border border-neutral-500 px-3 py-1.5 text-sm text-neutral-300 transition hover:bg-neutral-700"> <button wire:click="cancelEditName" class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm text-neutral-600 transition hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
Cancel Cancel
</button> </button>
</div> </div>
@error('editingPlaylistName') @error('editingPlaylistName')
<p class="text-rose-400 text-sm mb-1">{{ $message }}</p> <p class="text-rose-500 text-sm mb-1">{{ $message }}</p>
@enderror @enderror
@else @else
<h1 class="font-bold text-3xl"> <h1 class="flex items-center justify-center gap-1 text-3xl font-bold text-neutral-900 sm:justify-start dark:text-white">
{{ $playlist->name }} <span class="truncate">{{ $playlist->name }}</span>
@auth @auth
@if (Auth::id() === $playlist->user->id) @if (Auth::id() === $playlist->user->id)
<button wire:click="editName" class="ml-2 text-xl text-neutral-400 transition hover:text-white" title="Edit playlist name"> <button wire:click="editName" class="ml-1 text-xl text-neutral-400 transition hover:text-rose-500" title="Edit playlist name">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square"></i>
</button> </button>
@endif @endif
@endauth @endauth
</h1> </h1>
@endif @endif
<p class="font-light text-lg text-neutral-200">Episodes: {{ count($playlistEpisodes) }}</p> <p class="mt-1 text-lg font-light text-neutral-500 dark:text-neutral-300">{{ __('playlist.episodes') }}: {{ $playlist->episodes_count }}</p>
<p class="font-light text-lg text-neutral-200"> <p class="mt-0.5 text-lg font-light text-neutral-500 dark:text-neutral-300">
Creator: {{ $playlist->user->name }} Creator: {{ $playlist->user->name }}
@auth @auth
@if (Auth::id() === $playlist->user->id) @if (Auth::id() === $playlist->user->id)
<span class="ml-3"> <span class="ml-3">
<button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300 hover:bg-neutral-500' : 'bg-green-600 text-white hover:bg-green-500' }}"> <button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-200 text-neutral-700 hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600' : 'bg-green-600 text-white hover:bg-green-500' }}">
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i> <i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }} {{ $playlist->is_private ? 'Private' : 'Public' }}
</button> </button>
</span> </span>
@else @else
<span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300' : 'bg-green-600 text-white' }}"> <span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200' : 'bg-green-600 text-white' }}">
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i> <i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }} {{ $playlist->is_private ? 'Private' : 'Public' }}
</span> </span>
@@ -59,88 +60,86 @@
@endauth @endauth
</p> </p>
</div> </div>
<div class="flex flex-col justify-center pl-4">
<div class="flex justify-end"> <div class="shrink-0">
@php $episode = $playlistEpisodes->first()?->episode; @endphp @if ($firstEpisode)
@if(isset($episode)) <a href="{{ route('hentai.index', ['title' => $firstEpisode->slug, 'playlist' => $playlist->id]) }}"
<a href="{{ route('hentai.index', ['title' => $episode->slug, 'playlist' => $playlist->id]) }}" class="inline-flex items-center gap-2 text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">
class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a> <i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
@else </a>
<a class="cursor-not-allowed float-right text-white bg-neutral-700 focus:ring-4 focus:outline-none focus:ring-neutral-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-neutral-600 dark:focus:ring-neutral-800">{{ __('playlist.play') }}</a> @else
@endif <a class="inline-flex cursor-not-allowed items-center gap-2 text-neutral-500 bg-neutral-200 font-medium rounded-lg text-sm px-4 py-2 dark:text-neutral-300 dark:bg-neutral-700">
</div> <i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
</a>
@endif
</div> </div>
</div> </div>
@forelse($playlistEpisodes as $playlistEpisode) <!-- Toolbar -->
@php $episode = $playlistEpisode->episode; @endphp <div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}" <div class="relative flex-1">
class="flex justify-between items-center rounded-lg hover:bg-black border border-neutral-950 bg-neutral-950/50 backdrop-blur-lg transition !mt-1 gap-2"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<div class="flex pl-5 pr-5 w-10"> <svg class="h-5 w-5 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
{{ $playlistEpisode->position }} <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
</div> </div>
<div class="flex-[2] hidden md:block">
<div class="relative p-1 w-full md:p-2">
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
src="{{ $episode->gallery->first()->thumbnail_url }}">
@guest <input
<p wire:model.live.debounce.500ms="search"
class="absolute left-2 bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30 collapse md:visible"> type="search"
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i placeholder="{{ __('playlist.search-episodes') }}"
class="fa-regular fa-heart"></i> class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-12 pr-12 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
{{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i> />
{{ $episode->commentCount() }}
</p>
@endguest
@auth <div wire:loading.class="opacity-100" class="opacity-0">
@if ($episode->userWatched(auth()->user()->id)) <div class="absolute inset-y-0 right-3 flex items-center">
<p <svg class="h-5 w-5 animate-spin text-rose-500" viewBox="0 0 24 24" fill="none">
class="absolute left-2 bottom-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30"> <circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i <path class="opacity-90" fill="currentColor"
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i d="M22 12a10 10 0 0 1-10 10V18a6 6 0 0 0 6-6h4Z">
class="fa-regular fa-comment"></i> </path>
{{ $episode->commentCount() }} </svg>
</p>
@else
<p
class="absolute left-2 bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i
class="fa-regular fa-comment"></i>
{{ $episode->commentCount() }}
</p>
@endif
@endauth
</a>
</div> </div>
</div> </div>
<div class="flex-[5]">
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
class="font-bold">{{ $episode->title }} - {{ $episode->episode }}</a>
<br>
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">{{ $episode->title_jpn }}</a>
</div>
<div class="pr-5">
@auth
@if (Auth::user()->id === $playlist->user->id)
<button class="pr-2" wire:click="moveUp({{ $playlistEpisode->id }})"><i
class="fa-solid fa-arrow-up"></i></button>
<button class="pr-2" wire:click="moveDown({{ $playlistEpisode->id }})"><i
class="fa-solid fa-arrow-down"></i></button>
<button wire:click="remove({{ $playlistEpisode->id }})" class="text-red-500"><i
class="fa-solid fa-trash"></i></button>
@endif
@endauth
</div>
</div> </div>
@empty
<div class="pt-6 text-2xl text-center"> <div class="flex shrink-0 items-center gap-2">
No results (╥﹏╥) <label for="playlistPerPage" class="text-sm text-neutral-500 dark:text-neutral-400">
{{ __('playlist.per-page') }}
</label>
<select
wire:model.live="perPage"
id="playlistPerPage"
class="rounded-xl border border-neutral-300 bg-white py-2.5 pl-3 pr-8 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div> </div>
@endforelse </div>
<!-- List -->
<div class="space-y-2">
@forelse($episodes as $playlistEpisode)
@include('livewire.partials.playlist-episode-row', ['playlistEpisode' => $playlistEpisode, 'isOwner' => $isOwner])
@empty
<div
class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-clapperboard text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{{ $search !== '' ? __('playlist.no-matches') : __('playlist.empty-playlist') }}
</h3>
</div>
@endforelse
</div>
<!-- Pagination -->
@if ($episodes->hasPages())
<div class="mt-6">{{ $episodes->links('pagination::tailwind') }}</div>
@endif
</div> </div>
</div> </div>
@@ -0,0 +1,219 @@
<div
x-data="{
total: {{ $total }},
windowStart: {{ $windowStart }},
windowEnd: {{ $windowEnd }},
rowHeight: 80,
collapsible: @json($collapsible),
open: ! @json($collapsible),
loadingUp: false,
loadingDown: false,
rafPending: false,
init() {
this.$watch('open', (value) => {
if (value) {
setTimeout(() => this.scrollActiveIntoView(), 250);
}
});
if (! this.collapsible) {
this.$nextTick(() => this.scrollActiveIntoView());
}
},
get scroller() {
return this.$refs.scroller;
},
onScroll() {
if (this.rafPending) {
return;
}
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
this.evaluateScroll();
});
},
evaluateScroll() {
const scroller = this.scroller;
if (! scroller || scroller.clientHeight === 0) {
return;
}
const threshold = this.rowHeight * 2;
if (scroller.scrollTop <= threshold) {
this.loadUp();
}
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - threshold) {
this.loadDown();
}
},
async loadUp() {
if (this.loadingUp || this.windowStart <= 1) {
return;
}
this.loadingUp = true;
const before = this.windowStart;
try {
await $wire.prependChunk();
this.syncWindow();
const added = before - this.windowStart;
if (added > 0) {
this.scroller.scrollTop += added * this.rowHeight;
}
} finally {
this.loadingUp = false;
}
},
async loadDown() {
if (this.loadingDown || this.windowEnd >= this.total) {
return;
}
this.loadingDown = true;
const before = this.windowStart;
try {
await $wire.appendChunk();
this.syncWindow();
const trimmedFromTop = this.windowStart - before;
if (trimmedFromTop > 0) {
this.scroller.scrollTop -= trimmedFromTop * this.rowHeight;
}
} finally {
this.loadingDown = false;
}
},
syncWindow() {
const data = this.$el.dataset;
this.total = parseInt(data.total, 10);
this.windowStart = parseInt(data.windowStart, 10);
this.windowEnd = parseInt(data.windowEnd, 10);
},
scrollActiveIntoView() {
const scroller = this.scroller;
const active = scroller ? scroller.querySelector('[data-active-row]') : null;
if (! scroller || ! active) {
return;
}
scroller.scrollTop = active.offsetTop - scroller.clientHeight / 2 + this.rowHeight / 2;
},
}"
data-total="{{ $total }}"
data-window-start="{{ $windowStart }}"
data-window-end="{{ $windowEnd }}"
data-active-playlist-episode-id="{{ $currentPlaylistEpisodeId ?? 'null' }}"
>
<input id="playlist_id" type="hidden" value="{{ $playlist->id }}">
<input id="playlist_next_episode_slug" type="hidden" value="{{ $nextEpisodeSlug }}">
<div class="xl:sticky xl:top-[80px] xl:w-[420px]">
<div class="overflow-hidden rounded-2xl border border-neutral-200/70 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-950">
@if ($collapsible)
<button type="button" @click="open = ! open" :aria-expanded="open ? 'true' : 'false'"
class="flex w-full items-center justify-between gap-3 px-4 py-3 text-neutral-900 transition hover:bg-neutral-50 dark:text-white dark:hover:bg-neutral-900">
<span class="flex items-center gap-2 font-semibold">
<i class="fa-solid fa-list text-rose-600"></i>
{{ __('playlist.playlist') }} · {{ $total }} {{ __('playlist.episodes') }}
</span>
<i class="fa-solid fa-chevron-down text-neutral-400 transition-transform" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-collapse.duration.200ms>
@endif
<div class="p-4">
<a href="{{ $playlist->is_private ? route('profile.playlist.show', $playlist->id) : route('playlist.show', $playlist->id) }}"
class="flex min-w-0 items-center gap-2 text-neutral-900 transition hover:text-rose-500 dark:text-white dark:hover:text-rose-400">
<i class="fa-solid fa-list text-rose-600"></i>
<h3 class="truncate font-bold">{{ $playlist->name }}</h3>
<span class="ml-auto shrink-0 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{{ $total }} {{ __('playlist.episodes') }}
</span>
</a>
<div class="mt-2 flex items-center gap-2">
<img src="{{ $playlist->user->getAvatar() }}" alt="" class="h-6 w-6 rounded-full">
<span class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{ $playlist->user->name }}</span>
</div>
</div>
<div class="border-y border-neutral-200/70 px-4 py-3 dark:border-neutral-800">
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-rose-600 dark:text-rose-400">
<i class="fa-solid fa-play mr-1"></i>{{ __('playlist.now-playing') }}
</p>
<div class="flex items-center gap-3">
<img src="{{ $currentEpisode->gallery->first()?->thumbnail_url }}" alt=""
class="h-12 w-20 shrink-0 rounded-md object-cover" loading="eager" decoding="async">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-neutral-900 dark:text-white">
{{ $currentEpisode->title }}
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $currentPosition }}/{{ $total }} {{ __('playlist.episodes') }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
@if ($previousEpisodeSlug)
<a href="{{ route('hentai.index', ['title' => $previousEpisodeSlug, 'playlist' => $playlist->id]) }}"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
<i class="fa-solid fa-chevron-up"></i>
</a>
@endif
@if ($nextEpisodeSlug)
<a href="{{ route('hentai.index', ['title' => $nextEpisodeSlug, 'playlist' => $playlist->id]) }}"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
<i class="fa-solid fa-chevron-down"></i>
</a>
@endif
</div>
</div>
</div>
<div x-ref="scroller" x-on:scroll.passive="onScroll"
class="max-h-[50vh] overflow-y-auto overscroll-contain [overflow-anchor:none] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300 [&::-webkit-scrollbar-track]:bg-transparent dark:[&::-webkit-scrollbar-thumb]:bg-neutral-700 xl:max-h-[calc(100vh-340px)]">
<div wire:loading.delay wire:target="prependChunk"
class="flex items-center justify-center py-3 text-neutral-400">
<i class="fa-solid fa-spinner animate-spin"></i>
</div>
@forelse ($episodes as $playlistEpisode)
@include('livewire.partials.playlist-sidebar-row', [
'playlistEpisode' => $playlistEpisode,
'isActive' => $playlistEpisode->episode_id === $currentEpisodeId,
'isOwner' => $isOwner,
])
@empty
<div class="px-4 py-10 text-center">
<div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
<i class="fa-solid fa-clapperboard text-xl text-neutral-400 dark:text-neutral-500"></i>
</div>
<p class="mt-3 text-sm text-neutral-500 dark:text-neutral-400">{{ __('playlist.empty-playlist') }}</p>
</div>
@endforelse
<div wire:loading.delay wire:target="appendChunk"
class="flex items-center justify-center py-3 text-neutral-400">
<i class="fa-solid fa-spinner animate-spin"></i>
</div>
</div>
@if ($collapsible)
</div>
@endif
</div>
</div>
</div>
+193 -66
View File
@@ -1,27 +1,42 @@
<div> <div>
<!-- Search --> <!-- Hero -->
<div class="p-4 mx-3 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg"> <section
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> class="relative overflow-hidden rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-10 shadow-xl backdrop-blur-lg sm:px-10 sm:py-12 dark:border-neutral-800 dark:bg-neutral-950/40">
<div> <div class="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-rose-600/20 blur-3xl"></div>
<label for="live-search" <div class="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-neutral-500/20 blur-3xl dark:bg-rose-900/10"></div>
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
<div class="relative right-2 left-0 sm:left-2 transition-all"> <div class="relative z-10">
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none"> <h1 class="text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" {{ __('nav.public-playlists') }}
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> </h1>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <p class="mt-2 text-neutral-600 dark:text-neutral-400">
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" /> {{ __('playlist.hero-subtitle') }}
</svg> </p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<span
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
<i class="fa-solid fa-rectangle-list text-rose-600"></i>
{{ number_format($totalPlaylists) }} {{ __('playlist.playlists-count') }}
</span>
<span
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
<i class="fa-solid fa-film text-rose-600"></i>
{{ number_format($totalEpisodes) }} {{ __('home.episodes') }}
</span>
</div>
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="relative">
<label for="playlist-search" class="sr-only">Search</label>
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<i class="fa-solid fa-magnifying-glass text-neutral-400"></i>
</div> </div>
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search" placeholder="Search Playlist..."
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search" class="w-full rounded-full border border-neutral-200/70 bg-white/60 py-3 pl-11 pr-12 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm backdrop-blur transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/30 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-white dark:placeholder-neutral-500">
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" <div class="absolute inset-y-0 right-4 flex items-center" wire:loading>
placeholder="Search Playlist..."> <svg aria-hidden="true" class="h-5 w-5 animate-spin text-neutral-400" viewBox="0 0 100 101" fill="none"
xmlns="http://www.w3.org/2000/svg">
<div class="absolute right-0 top-[11px]" wire:loading>
<svg aria-hidden="true"
class="inline w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-pink-600"
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" /> fill="currentColor" />
@@ -31,53 +46,165 @@
</svg> </svg>
</div> </div>
</div> </div>
</div>
<!-- Ordering --> <div class="relative">
<div class="relative right-2 left-0 sm:left-2 transition-all"> <div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none"> <i class="fa-solid fa-sort text-neutral-400"></i>
<i class="fa-solid fa-sort text-gray-500 dark:text-gray-400"></i> </div>
<select wire:model.live="order"
class="w-full appearance-none rounded-full border border-neutral-200/70 bg-white/60 py-3 pl-11 pr-10 text-sm text-neutral-900 shadow-sm backdrop-blur transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/30 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-white">
<option value="az">A-Z</option>
<option value="za">Z-A</option>
<option value="episode-count">Episode count</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-4 flex items-center">
<i class="fa-solid fa-chevron-down text-neutral-400"></i>
</div>
</div> </div>
<select wire:model.live="order"
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900">
<option value="az">A-Z</option>
<option value="za">Z-A</option>
<option value="episode-count">Episode count</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
</select>
</div> </div>
</div> </div>
</section>
<!-- Grid -->
<div class="mt-8" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
<div wire:loading.grid
class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@for ($i = 0; $i < 8; $i++)
<div
class="overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/50">
<div class="relative aspect-video overflow-hidden">
<div class="h-full w-full animate-pulse bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
</div>
<div class="space-y-3 p-5">
<div class="h-4 w-3/4 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
<div class="h-3 w-1/2 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
</div>
</div>
@endfor
</div>
<div wire:loading.remove>
@if ($playlists->isEmpty())
<div
class="rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-16 text-center shadow-xl backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/40">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-rose-600/10 text-rose-600">
<i class="fa-solid fa-rectangle-list text-2xl"></i>
</div>
<p class="mt-4 text-lg font-semibold text-neutral-900 dark:text-white">
{{ __('playlist.no-playlist-found') }}
</p>
@if ($search !== '')
<button type="button" wire:click="$set('search', '')"
class="mt-4 inline-flex items-center gap-2 rounded-full bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700">
<i class="fa-solid fa-xmark"></i>
{{ __('playlist.clear-search') }}
</button>
@endif
</div>
@else
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@foreach ($playlists as $playlist)
@php
$covers = $playlist->episodes
->take(4)
->map(fn ($pe) => $pe->episode?->gallery->first()?->thumbnail_url)
->filter()
->values();
@endphp
<div wire:key="playlist-{{ $playlist->id }}"
class="group flex flex-col overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/30 dark:border-neutral-800 dark:bg-neutral-950/50 dark:hover:border-neutral-700">
<a href="{{ route('playlist.show', $playlist->id) }}"
class="relative block aspect-video overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-900 to-neutral-950">
@if ($covers->isEmpty())
<div class="flex h-full w-full items-center justify-center">
<i class="fa-solid fa-rectangle-list text-4xl text-neutral-500"></i>
</div>
@elseif ($covers->count() === 1)
<img src="{{ $covers[0] }}" alt="{{ $playlist->name }}" loading="lazy"
class="h-full w-full object-cover">
@elseif ($covers->count() === 2)
<div class="grid h-full w-full grid-cols-2">
@foreach ($covers as $cover)
<img src="{{ $cover }}" alt="" loading="lazy"
class="h-full w-full object-cover">
@endforeach
</div>
@elseif ($covers->count() === 3)
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
<img src="{{ $covers[0] }}" alt="" loading="lazy"
class="col-span-2 h-full w-full object-cover">
<img src="{{ $covers[1] }}" alt="" loading="lazy"
class="h-full w-full object-cover">
<img src="{{ $covers[2] }}" alt="" loading="lazy"
class="h-full w-full object-cover">
</div>
@else
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
@foreach ($covers->take(4) as $cover)
<img src="{{ $cover }}" alt="" loading="lazy"
class="h-full w-full object-cover">
@endforeach
</div>
@endif
<span
class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
<i class="fa-solid fa-clapperboard"></i>
{{ $playlist->episodes_count }}
</span>
<div
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition duration-300 group-hover:opacity-100">
<span
class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-600 shadow-lg transition-transform duration-300 group-hover:scale-110">
<i class="fa-solid fa-play pl-1 text-lg text-white"></i>
</span>
</div>
</a>
<div class="flex flex-1 flex-col p-5">
<h3 class="text-base font-semibold text-neutral-900 line-clamp-2 dark:text-neutral-50">
<a href="{{ route('playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
</h3>
<div class="mt-2 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
<img src="{{ $playlist->user?->getAvatar() ?? asset('images/default-avatar.webp') }}"
alt="" class="h-5 w-5 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-700">
<span class="truncate">{{ $playlist->user?->name }}</span>
<span>·</span>
<span class="shrink-0">{{ $playlist->created_at->diffForHumans() }}</span>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500 dark:text-neutral-400">
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
</span>
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-rose-600 to-rose-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:from-rose-700 hover:to-rose-800">
<i class="fa-solid fa-play"></i>
{{ __('playlist.play') }}
</a>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div> </div>
<div class="grid-cols-1 sm:grid md:grid-cols-3" wire:keydown.right.window="nextPage" @if ($playlists->hasPages())
wire:keydown.left.window="previousPage"> <div class="mt-10 mb-10">
{{ $playlists->links('pagination::tailwind') }}
@foreach ($playlists as $playlist) </div>
<div wire:key="playlist-{{ $playlist->id }}" @endif
class="mx-3 mt-6 flex flex-col rounded-lg bg-white shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/50 backdrop-blur-lg sm:shrink-0 sm:grow sm:basis-0 z-10">
<a href="{{ route('playlist.show', $playlist->id) }}">
@php
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)
->orderBy('position', 'desc')
->first();
@endphp
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
alt="Hollywood Sign on The Hill" />
</a>
<div class="p-6">
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
{{ $playlist->name }}
</h5>
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a>
</p>
</div>
</div>
@endforeach
</div>
<div class="mt-10 mb-10">
{{ $playlists->links('pagination::tailwind') }}
</div>
</div> </div>
+26 -34
View File
@@ -1,36 +1,28 @@
<div class="grid grid-cols-2"> <div class="flex w-full items-center justify-between gap-3 px-3 py-2">
<p class="cursor-default">{{ __('Blur effects') }}</p> <p class="cursor-default text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('Blur effects') }}</p>
<div class="flex items-center"> <label for="toggleBlur" class="relative flex cursor-pointer items-center">
<div class="absolute right-6"> <!-- input -->
<label for="toggleBlur" class="flex items-center cursor-pointer"> <input id="toggleBlur" type="checkbox" class="sr-only" checked />
<!-- toggle --> <!-- line -->
<div class="relative"> <div class="w-9 h-5 bg-rose-600 dark:bg-neutral-700 rounded-full shadow-inner">
<!-- input -->
<input id="toggleBlur" type="checkbox" class="sr-only" checked />
<!-- line -->
<div class="w-10 h-4 bg-rose-600 dark:bg-gray-400 rounded-full shadow-inner">
</div>
<!-- dot -->
<div
class="dot absolute w-6 h-6 bg-white dark:bg-neutral-950 rounded-full shadow -left-1 -top-1 transition">
<div class="items-center ml-[4px] w-6 h-6 font-medium flex">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-blur">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035z" />
<path d="M12 3v17" />
<path d="M12 12h9" />
<path d="M12 9h8" />
<path d="M12 6h6" />
<path d="M12 18h6" />
<path d="M12 15h8" />
</svg>
</div>
</div>
</div>
</label>
</div> </div>
</div> <!-- dot -->
<div class="dot absolute w-4 h-4 bg-white dark:bg-neutral-950 rounded-full shadow left-0.5 top-0.5 transition">
<div class="flex h-full w-full items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-blur text-gray-600 dark:text-gray-200">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035z" />
<path d="M12 3v17" />
<path d="M12 12h9" />
<path d="M12 9h8" />
<path d="M12 6h6" />
<path d="M12 18h6" />
<path d="M12 15h8" />
</svg>
</div>
</div>
</label>
</div> </div>
@@ -1,16 +1,10 @@
<label for="toogleTheme" class="flex items-center cursor-pointer"> <label for="toogleTheme"
<!-- toggle --> class="relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full ring-1 ring-gray-200 transition-colors hover:bg-rose-50 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
<div class="relative"> <!-- input -->
<!-- input --> <input id="toogleTheme" type="checkbox" class="sr-only" />
<input id="toogleTheme" type="checkbox" class="sr-only" /> <!-- icon -->
<!-- line --> <div class="theme-icon flex items-center justify-center transition-transform duration-200">
<div class="w-10 h-4 bg-rose-600 dark:bg-gray-400 rounded-full shadow-inner"></div> <i class="fa-regular fa-moon text-gray-600 dark:text-gray-200 hidden dark:inline"></i>
<!-- dot --> <i class="fa-regular fa-sun text-yellow-400 dark:hidden"></i>
<div class="dot absolute w-6 h-6 bg-white dark:bg-neutral-950 rounded-full shadow -left-1 -top-1 transition">
<div class="items-center ml-[5px] w-6 h-6 font-medium flex">
<i class="fa-regular fa-moon text-white hidden dark:inline"></i>
<i class="fa-regular fa-sun text-yellow-400 dark:hidden"></i>
</div>
</div>
</div> </div>
</label> </label>
+1 -7
View File
@@ -1,11 +1,5 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"> <div class="mx-auto pt-6 sm:px-6 lg:px-8 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%]">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight z-10">
{{ __('nav.public-playlists') }}
</h2>
</x-slot>
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
@include('partials.background') @include('partials.background')
@livewire('playlists') @livewire('playlists')
</div> </div>
+6 -2
View File
@@ -16,7 +16,9 @@
@if($isMobile) @if($isMobile)
<div class="flex flex-col"> <div class="flex flex-col">
@include('stream.partials.playlist') @isset($playlist)
<livewire:playlist-sidebar :playlist-id="$playlist->id" :current-episode-id="$episode->id" :collapsible="true" />
@endisset
</div> </div>
@endif @endif
@@ -27,7 +29,9 @@
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@if(! $isMobile) @if(! $isMobile)
@include('stream.partials.playlist') @isset($playlist)
<livewire:playlist-sidebar :playlist-id="$playlist->id" :current-episode-id="$episode->id" />
@endisset
@endif @endif
@include('stream.partials.more-episodes') @include('stream.partials.more-episodes')
@@ -1,94 +0,0 @@
@isset($playlist)
<div class="pt-2 sm:px-2 lg:px-4 2xl:w-[450px]">
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800">
<div class="p-4">
<p class="leading-normal font-bold text-lg text-rose-600 pb-2">
@if ($playlist->is_private)
<a href="{{ route('profile.playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
@else
<a href="{{ route('playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
@endif
</p>
@php
$episodeCount = $playlistEpisodes->count();
$currentIndex = 0;
$nextEpisode = "";
if ($episodeCount > 1) {
$currentIndex = $playlistEpisodes->search(fn($playlistEpisode) => $playlistEpisode->episode->id == $episode->id);
$nextEpisode = $currentIndex !== false && $currentIndex + 1 < $episodeCount
? $playlistEpisodes[$currentIndex + 1]->episode->slug
: "";
}
@endphp
<p class="text-neutral-800 dark:text-neutral-300">
{{ $playlist->user->name }} {{ $currentIndex + 1 }}/{{ $episodeCount }} Episodes
</p>
</div>
<!-- Table -->
<div id="scrollable" class="flex-none min-w-full px-4 sm:px-6 md:px-0 overflow-auto scrollbar:!w-1.5 scrollbar:!h-1.5 scrollbar:bg-transparent scrollbar-track:!bg-slate-100 scrollbar-thumb:!rounded scrollbar-thumb:!bg-slate-300 scrollbar-track:!rounded dark:scrollbar-track:!bg-slate-500/[0.16] dark:scrollbar-thumb:!bg-slate-500/50 max-h-96 lg:supports-scrollbars:pr-2 lg:max-h-96">
<div class="overflow-y-auto">
<div class="space-y-2 p-0 pb-2 sm:p-2">
@php
$counter = 1;
$isAuthedUsersPlaylist = false;
if (auth()->check() && $playlist->user->id == auth()->user()->id) {
$isAuthedUsersPlaylist = true;
}
@endphp
@foreach($playlistEpisodes as $playlistEpisode)
@if ($playlistEpisode->episode->id == $episode->id)
<div class="flex items-center gap-4 p-2 bg-rose-800/30 rounded-lg shadow swipe-container transition-colors" id="active">
@else
<div
class="flex items-center gap-4 p-2 dark:bg-neutral-900/50 bg-white rounded-lg shadow transition-colors @if($isMobile && $isAuthedUsersPlaylist) swipe-container @endif"
id="{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}">
@endif
<div class="text-black dark:text-white">
@if ($playlistEpisode->episode->id == $episode->id)
<i class="fa-solid fa-play w-[15px]"></i>
@else
<p class="w-[15px]">{{ $counter }}</p>
@endif
</div>
<a href="{{ route('hentai.index', ['title' => $playlistEpisode->episode->slug, 'playlist' => $playlist->id ]) }}" class="contents">
<img loading="lazy" src="{{ $playlistEpisode->episode->gallery->first()->thumbnail_url }}" alt="{{ $playlistEpisode->episode->title }} - {{ $playlistEpisode->episode->episode }}" class="w-20 h-14 object-cover rounded">
</a>
<div class="grow">
<a href="{{ route('hentai.index', ['title' => $playlistEpisode->episode->slug, 'playlist' => $playlist->id ]) }}">
<p class="text-black dark:text-white font-medium text-sm break-words">{{ $playlistEpisode->episode->title }} - {{ $playlistEpisode->episode->episode }}</p>
</a>
<p class="text-gray-700 dark:text-gray-300 text-xs truncate">{{ $playlistEpisode->episode->viewCount() }} Views - {{ $playlistEpisode->episode->studio->name }}</p>
</div>
@if ($playlistEpisode->episode->id != $episode->id && $isAuthedUsersPlaylist)
@if($isMobile)
<div class="justify-self-end flex items-center">
<i class="transition-all fa-solid fa-grip-lines-vertical cursor-grab text-black dark:text-white" id="del-{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}"></i>
</div>
@else
<div class="justify-self-end flex items-center">
<a class="transition-all fa-solid fa-trash cursor-pointer text-red-700/80" id="delD-{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}"></a>
</div>
@endif
@endif
</div>
@php $counter++; @endphp
@endforeach
</div>
</div>
</div>
</div>
</div>
<input id="playlist_id" type="hidden" value="{{ $playlist->id }}">
<input id="playlist_next_episode_slug" type="hidden" value="{{ $nextEpisode }}">
<script>
// Select the scrollable div and the target child element
const scrollableDiv = document.getElementById('scrollable');
const targetElement = document.getElementById('active');
// Scroll to the target element
scrollableDiv.scrollTop = targetElement.offsetTop - scrollableDiv.offsetTop - 50;
</script>
@endisset
-5
View File
@@ -41,11 +41,6 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
// Release // Release
Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index'); Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index');
Route::post('/admin/release/upload', [ReleaseController::class, 'store'])->name('admin.upload');
// Episode
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
// Get Tags used for Upload Form // Get Tags used for Upload Form
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags'); Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
-54
View File
@@ -1,54 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
}
}
@@ -1,58 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}
@@ -1,44 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}
-73
View File
@@ -1,73 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
}
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use Tests\RefreshDatabase;
use Tests\Support\AltchaPayload;
use Tests\TestCase;
class ContactFormTest extends TestCase
{
use RefreshDatabase;
public function test_contact_requires_fields(): void
{
$this->post('/contact', [])
->assertSessionHasErrors(['name', 'email', 'message', 'subject', 'altcha']);
}
public function test_valid_contact_submission_creates_row(): void
{
$this->post('/contact', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'subject' => 'Bug report',
'message' => 'The search page looks broken on mobile.',
'altcha' => AltchaPayload::valid(),
])->assertRedirect();
$this->assertDatabaseHas('contacts', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'subject' => 'Bug report',
'message' => 'The search page looks broken on mobile.',
]);
}
}
+1 -1
View File
@@ -7,10 +7,10 @@ use App\Models\Gallery;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\Studios; use App\Models\Studios;
use App\Services\GalleryService; use App\Services\GalleryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Tests\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class GalleryServiceTest extends TestCase class GalleryServiceTest extends TestCase
@@ -0,0 +1,283 @@
<?php
namespace Tests\Feature\Livewire;
use App\Enums\UserRole;
use App\Livewire\AdminEpisodeForm;
use App\Models\Downloads;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\Studios;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\RefreshDatabase;
use Tests\TestCase;
class AdminEpisodeFormTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Cache::flush();
Storage::fake('public');
config([
'hstream.download_domain' => ['https://dl-a.test'],
'hstream.download_domain_4k' => ['https://dl4k-a.test'],
'hstream.stream_domain' => ['https://stream-a.test'],
]);
$this->admin = User::factory()->create();
$this->admin->addRole(UserRole::ADMINISTRATOR);
$this->actingAs($this->admin);
}
private function makeReferenceEpisode(string $slug = 'amazing-title-2026', array $attributes = []): Episode
{
$hentai = Hentai::factory()->create([
'slug' => $slug,
'description' => 'An existing series',
]);
$studio = Studios::factory()->create([
'name' => 'Test Studio',
'slug' => 'test-studio',
]);
$episode = Episode::factory()->create(array_merge([
'hentai_id' => $hentai->id,
'studios_id' => $studio->id,
'title' => 'Amazing Title 2026',
'title_search' => 'Amazing Title 2026',
'title_jpn' => '素晴らしいタイトル',
'slug' => $slug.'-1',
'episode' => 1,
'description' => 'A lovely description',
'url' => '2026/AmazingTitle/E01',
'cover_url' => '/images/hentai/'.$slug.'/cover-ep-1.webp',
'interpolated' => 1,
'interpolated_uhd' => 1,
'release_date' => '2026-08-04',
'view_count' => 0,
], $attributes));
$episode->tag('Action');
return $episode;
}
private function makeForm(?Episode $episode = null): Testable
{
$episode ??= $this->makeReferenceEpisode();
return Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id]);
}
public function test_valid_episode_is_persisted(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
$this->makeForm()
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('gallery', [
UploadedFile::fake()->image('g1.jpg'),
UploadedFile::fake()->image('g2.jpg'),
])
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhdi', '2026/AmazingTitle/E02.mkv')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
$this->assertDatabaseCount('episodes', 2);
$episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail();
$this->assertSame('Amazing Title 2026', $episode->title);
$this->assertSame('素晴らしいタイトル', $episode->title_jpn);
$this->assertSame('Test Studio', $episode->studio->name);
$this->assertSame(2, $episode->episode);
$this->assertSame('2026/AmazingTitle/E02', $episode->url);
$this->assertSame('2026-08-04', $episode->release_date);
$this->assertSame(1, $episode->interpolated);
$this->assertSame(1, $episode->interpolated_uhd);
$this->assertTrue($episode->tags->contains('name', 'Action'));
$this->assertDatabaseCount('gallery', 2);
$this->assertDatabaseCount('downloads', 3);
$fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E02.mkv', $fhd->url);
$this->assertEquals(9999, $fhd->size);
$this->assertNotNull($fhd->validated_at);
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
$this->assertEquals(9999, $uhd->size);
$uhdi = Downloads::where('episode_id', $episode->id)->where('type', 'UHDi')->firstOrFail();
$this->assertEquals(9999, $uhdi->size);
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/cover-ep-2.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-0.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-1.webp'));
}
public function test_invalid_cdn_path_blocks_save_with_error(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
]);
$this->makeForm()
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->call('save')
->assertHasErrors('downloads.fhd');
$this->assertDatabaseCount('episodes', 1);
}
public function test_override_validation_allows_saving_invalid_paths(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
]);
$this->makeForm()
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->set('overrideValidation', true)
->call('save')
->assertHasNoErrors()
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
$this->assertDatabaseCount('episodes', 2);
$episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail();
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E02.mkv', $uhd->url);
$this->assertNull($uhd->size);
}
public function test_validation_requires_required_fields(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
$component = $this->makeForm()
->set('baseurl', '')
->set('description', '')
->set('downloads.fhd', '')
->set('downloads.uhd', '')
->call('save')
->assertHasErrors([
'baseurl' => 'required',
'description' => 'required',
'cover' => 'required',
'downloads.fhd' => 'required',
'downloads.uhd' => 'required',
]);
$this->assertDatabaseCount('episodes', 1);
// The base URL must match the directory pattern (2026/Title).
$component
->set('baseurl', 'no-slash')
->set('description', 'A lovely description')
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->call('save')
->assertHasErrors(['baseurl' => 'regex']);
$this->assertDatabaseCount('episodes', 1);
}
public function test_save_uses_fresh_cdn_check_ignoring_cached_results(): void
{
$valid = false;
Http::fake(function ($request) use (&$valid) {
return Http::response(['valid' => $valid, 'type' => 'file', 'size' => 9999]);
});
$component = $this->makeForm()
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv');
// Live typing validation caches the invalid CDN result.
$validation = $component->get('validation');
$this->assertSame('invalid', $validation['downloads.fhd']['state'] ?? null);
// The file gets "fixed" on the CDN within the cache window.
$valid = true;
// Saving must re-check the CDN without using the cached invalid result.
$component
->call('save')
->assertHasNoErrors()
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
$this->assertDatabaseCount('episodes', 2);
$this->assertDatabaseCount('downloads', 2);
}
public function test_mount_and_save_require_admin_role(): void
{
$episode = $this->makeReferenceEpisode();
// Guests are rejected when the component mounts.
Auth::logout();
Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id])
->assertStatus(403);
// Authenticated non-admins are rejected when the component mounts.
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id])
->assertStatus(403);
// Administrators can mount and save.
$admin = User::factory()->create();
$admin->addRole(UserRole::ADMINISTRATOR);
$this->actingAs($admin);
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
$this->makeForm($episode)
->set('cover', UploadedFile::fake()->image('cover.jpg'))
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
$this->assertDatabaseCount('episodes', 2);
}
public function test_stream_page_renders_episode_modal_for_admin(): void
{
$episode = $this->makeReferenceEpisode('amazing-title-2026', [
'interpolated' => 0,
'interpolated_uhd' => 0,
]);
$this->get(route('hentai.index', $episode->slug))
->assertOk()
->assertSee('admin-episode-form');
}
}
@@ -0,0 +1,238 @@
<?php
namespace Tests\Feature\Livewire;
use App\Enums\UserRole;
use App\Livewire\AdminReleaseForm;
use App\Models\Downloads;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\RefreshDatabase;
use Tests\TestCase;
class AdminReleaseFormTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Cache::flush();
Storage::fake('public');
config([
'hstream.download_domain' => ['https://dl-a.test'],
'hstream.download_domain_4k' => ['https://dl4k-a.test'],
'hstream.stream_domain' => ['https://stream-a.test'],
]);
}
private function makeForm(): Testable
{
return Livewire::test(AdminReleaseForm::class)
->set('title', 'Amazing Title 2026')
->set('titleJpn', '素晴らしいタイトル')
->set('studio', 'Test Studio')
->set('tags', ['Action', 'Romance'])
->set('releasedate', '2026-08-04')
->set('baseurl', '2026/AmazingTitle')
->set('episodes.0.description', 'A lovely description');
}
public function test_valid_release_is_persisted_with_sizes(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.gallery', [
UploadedFile::fake()->image('g1.jpg'),
UploadedFile::fake()->image('g2.jpg'),
])
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhdi', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('home.index'));
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseHas('hentais', ['slug' => 'amazing-title-2026']);
$hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail();
$this->assertDatabaseCount('episodes', 1);
$episode = Episode::where('hentai_id', $hentai->id)->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01', $episode->url);
$this->assertSame(1, $episode->interpolated);
$this->assertSame(1, $episode->interpolated_uhd);
$this->assertDatabaseCount('gallery', 2);
$this->assertDatabaseCount('downloads', 3);
$fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01.mkv', $fhd->url);
$this->assertEquals(9999, $fhd->size);
$this->assertNotNull($fhd->validated_at);
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
$this->assertEquals(9999, $uhd->size);
$uhdi = Downloads::where('episode_id', $episode->id)->where('type', 'UHDi')->firstOrFail();
$this->assertEquals(9999, $uhdi->size);
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/cover-ep-1.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-0.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-1.webp'));
}
public function test_invalid_cdn_path_blocks_save_with_error(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
]);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasErrors('episodes.0.downloads.fhd');
$this->assertDatabaseCount('hentais', 0);
$this->assertDatabaseCount('episodes', 0);
}
public function test_override_validation_allows_saving_invalid_paths(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
]);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('overrideValidation', true)
->call('save')
->assertHasNoErrors()
->assertRedirect(route('home.index'));
$this->assertDatabaseCount('hentais', 1);
$hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail();
$episode = Episode::where('hentai_id', $hentai->id)->firstOrFail();
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01.mkv', $uhd->url);
$this->assertNull($uhd->size);
}
public function test_existing_release_created_today_is_blocked(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
Hentai::create(['slug' => 'amazing-title-2026', 'description' => 'existing']);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasErrors('title');
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseCount('episodes', 0);
}
public function test_validation_requires_required_fields(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
Livewire::test(AdminReleaseForm::class)
->call('save')
->assertHasErrors([
'title' => 'required',
'titleJpn' => 'required',
'studio' => 'required',
'tags' => 'required',
'releasedate' => 'required',
'baseurl' => 'required',
'episodes.0.description' => 'required',
'episodes.0.cover' => 'required',
'episodes.0.downloads.fhd' => 'required',
'episodes.0.downloads.uhd' => 'required',
]);
$this->assertDatabaseCount('hentais', 0);
}
public function test_save_uses_fresh_cdn_check_ignoring_cached_results(): void
{
$valid = false;
Http::fake(function ($request) use (&$valid) {
return Http::response(['valid' => $valid, 'type' => 'file', 'size' => 9999]);
});
$component = $this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv');
// Live typing validation caches the invalid CDN result.
$validation = $component->get('validation');
$this->assertSame('invalid', $validation['episodes.0.downloads.fhd']['state'] ?? null);
// The file gets "fixed" on the CDN within the cache window.
$valid = true;
// Saving must re-check the CDN without using the cached invalid result.
$component
->call('save')
->assertHasNoErrors()
->assertRedirect(route('home.index'));
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseCount('downloads', 2);
}
public function test_admin_release_page_renders(): void
{
$user = User::factory()->create();
$user->addRole(UserRole::ADMINISTRATOR);
$this->actingAs($user)
->get(route('admin.upload.index'))
->assertOk()
->assertSee('admin-release-form');
}
public function test_tagify_inputs_are_excluded_from_livewire_morphing(): void
{
$component = Livewire::test(AdminReleaseForm::class);
// Tagify inputs must live inside wire:ignore scopes so Livewire's
// DOM morphing never replaces the Tagify-generated markup.
$component
->assertSeeHtml('wire:ignore x-data="adminReleaseTags"')
->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"');
// The wrappers must survive a server round-trip (e.g. a debounced
// download/baseurl update) untouched.
$component
->set('title', 'Changed Title')
->assertSeeHtml('wire:ignore x-data="adminReleaseTags"')
->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\LikeButton;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\Studios;
use App\Models\User;
use Livewire\Livewire;
use Maize\Markable\Models\Like;
use Tests\RefreshDatabase;
use Tests\TestCase;
class LikeButtonTest extends TestCase
{
use RefreshDatabase;
private function makeEpisode(): Episode
{
$hentai = Hentai::factory()->create();
$studio = Studios::factory()->create();
return Episode::factory()->create([
'hentai_id' => $hentai->id,
'studios_id' => $studio->id,
]);
}
public function test_guest_like_is_a_noop(): void
{
$episode = $this->makeEpisode();
Livewire::test(LikeButton::class, ['episode' => $episode])
->call('like');
$this->assertDatabaseCount('markable_likes', 0);
}
public function test_user_can_toggle_like(): void
{
$episode = $this->makeEpisode();
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(LikeButton::class, ['episode' => $episode])
->call('like')
->assertSet('liked', true)
->assertSet('likeCount', 1);
$this->assertTrue(Like::has($episode, $user));
Livewire::test(LikeButton::class, ['episode' => $episode->fresh()])
->call('like')
->assertSet('liked', false)
->assertSet('likeCount', 0);
$this->assertFalse(Like::has($episode, $user));
}
}
@@ -0,0 +1,249 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\PlaylistOverview;
use App\Models\Episode;
use App\Models\Gallery;
use App\Models\Hentai;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Studios;
use App\Models\User;
use Livewire\Livewire;
use Tests\RefreshDatabase;
use Tests\TestCase;
class PlaylistOverviewTest extends TestCase
{
use RefreshDatabase;
private function makePlaylist(int $episodeCount = 1): array
{
$user = User::factory()->create();
$playlist = Playlist::factory()->create(['user_id' => $user->id]);
$hentai = Hentai::factory()->create();
$studio = Studios::factory()->create();
for ($i = 1; $i <= $episodeCount; $i++) {
$episode = Episode::factory()->create([
'hentai_id' => $hentai->id,
'studios_id' => $studio->id,
'title' => sprintf('Episode %02d', $i),
]);
Gallery::factory()->create([
'hentai_id' => $hentai->id,
'episode_id' => $episode->id,
]);
PlaylistEpisode::factory()->create([
'playlist_id' => $playlist->id,
'episode_id' => $episode->id,
'position' => $i,
]);
}
return [$user, $playlist];
}
public function test_episodes_are_paginated(): void
{
[, $playlist] = $this->makePlaylist(30);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 30
&& $episodes->count() === 25
&& $episodes->hasPages())
->assertSee('Episode 01')
->assertSee('Episode 25')
->assertDontSee('Episode 26');
}
public function test_per_page_selector_changes_page_size(): void
{
[, $playlist] = $this->makePlaylist(30);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->set('perPage', 100)
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 30
&& $episodes->count() === 30
&& ! $episodes->hasPages())
->assertSee('Episode 30');
}
public function test_per_page_is_restricted_to_allowed_values(): void
{
[, $playlist] = $this->makePlaylist(30);
Livewire::withQueryParams(['perPage' => 999])
->test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->assertSet('perPage', 25)
->assertViewHas('episodes', fn ($episodes) => $episodes->perPage() === 25);
}
public function test_search_filters_episodes_and_resets_page(): void
{
[, $playlist] = $this->makePlaylist(30);
$hentai = Hentai::factory()->create();
$studio = Studios::factory()->create();
$uniqueEpisode = Episode::factory()->create([
'hentai_id' => $hentai->id,
'studios_id' => $studio->id,
'title' => 'ZebraUnicornSearch',
]);
Gallery::factory()->create([
'hentai_id' => $hentai->id,
'episode_id' => $uniqueEpisode->id,
]);
PlaylistEpisode::factory()->create([
'playlist_id' => $playlist->id,
'episode_id' => $uniqueEpisode->id,
'position' => 31,
]);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('gotoPage', 2)
->assertSet('paginators.page', 2)
->set('search', 'ZebraUnicornSearch')
->assertSet('paginators.page', 1)
->assertSee('ZebraUnicornSearch')
->assertDontSee('Episode 02');
}
public function test_guest_sees_no_owner_controls(): void
{
[, $playlist] = $this->makePlaylist(3);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->assertDontSee('wire:click="moveUp')
->assertDontSee('wire:click="moveDown')
->assertDontSee('wire:click="remove');
}
public function test_only_owner_can_move_episodes(): void
{
[$user, $playlist] = $this->makePlaylist(3);
$otherUser = User::factory()->create();
$this->actingAs($otherUser);
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('moveUp', $second->id);
$this->assertSame(2, PlaylistEpisode::find($second->id)->position);
$this->assertSame(
[1, 2, 3],
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
);
}
public function test_owner_can_move_episode_up_and_down(): void
{
[$user, $playlist] = $this->makePlaylist(3);
$this->actingAs($user);
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('moveUp', $second->id)
->assertOk();
$this->assertSame(1, PlaylistEpisode::find($second->id)->position);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('moveDown', $second->id)
->assertOk();
$this->assertSame(2, PlaylistEpisode::find($second->id)->position);
$this->assertSame(
[1, 2, 3],
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
);
}
public function test_owner_can_remove_episode_and_positions_reorder(): void
{
[$user, $playlist] = $this->makePlaylist(5);
$this->actingAs($user);
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('remove', $second->id)
->assertOk();
$this->assertDatabaseMissing('playlist_episodes', ['id' => $second->id]);
$this->assertSame(
[1, 2, 3, 4],
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
);
}
public function test_removing_last_item_on_last_page_clamps_page(): void
{
[$user, $playlist] = $this->makePlaylist(26);
$this->actingAs($user);
$last = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 26)->first();
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('gotoPage', 2)
->assertSet('paginators.page', 2)
->call('remove', $last->id)
->assertSet('paginators.page', 1)
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 25 && ! $episodes->hasPages());
}
public function test_edit_name_and_toggle_visibility_still_work(): void
{
[$user, $playlist] = $this->makePlaylist(2);
$this->actingAs($user);
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
->call('editName')
->set('editingPlaylistName', 'Renamed Playlist')
->call('updateName')
->call('toggleVisibility')
->assertOk();
$this->assertDatabaseHas('playlists', [
'id' => $playlist->id,
'name' => 'Renamed Playlist',
'is_private' => false,
]);
}
public function test_public_playlist_page_renders(): void
{
$user = User::factory()->create();
$playlist = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => false]);
$this->get('/playlist/'.$playlist->id)->assertOk();
$private = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => true]);
$this->get('/playlist/'.$private->id)->assertNotFound();
}
public function test_user_playlist_page_renders(): void
{
$user = User::factory()->create();
$playlist = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => true]);
$this->actingAs($user)->get('/user/playlist/'.$playlist->id)->assertOk();
$otherUser = User::factory()->create();
$this->actingAs($otherUser)->get('/user/playlist/'.$playlist->id)->assertNotFound();
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature;
use App\Enums\UserRole;
use App\Models\User;
use Tests\RefreshDatabase;
use Tests\TestCase;
class NavigationTest extends TestCase
{
use RefreshDatabase;
public function test_home_page_renders_new_nav(): void
{
$this->get('/search')
->assertOk()
->assertSee('Browse')
->assertSee('Playlists')
->assertSeeHtml('id="toogleTheme"');
}
public function test_admin_layout_renders_for_admin(): void
{
$admin = User::factory()->create();
$admin->addRole(UserRole::ADMINISTRATOR);
$this->actingAs($admin)
->get(route('admin.upload.index'))
->assertOk();
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Tests\RefreshDatabase;
use Tests\TestCase;
class PlaylistCreationTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_playlist(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/create-playlist', ['name' => 'My List', 'visiblity' => 'private'])
->assertRedirect(route('profile.playlists'));
$this->assertDatabaseHas('playlists', [
'user_id' => $user->id,
'name' => 'My List',
'is_private' => true,
]);
}
public function test_playlist_requires_a_name(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/create-playlist', [])
->assertSessionHasErrors('name');
}
}
-99
View File
@@ -1,99 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\Studios;
use Tests\RefreshDatabase;
use Tests\TestCase;
class PublicPagesTest extends TestCase
{
use RefreshDatabase;
public function test_login_page_renders(): void
{
$this->get('/login')->assertOk();
}
public function test_search_page_renders(): void
{
$this->get('/search')->assertOk();
}
public function test_contact_page_renders(): void
{
$this->get('/contact')->assertOk();
}
public function test_guest_is_redirected_to_login(): void
{
$this->get('/user/profile')->assertRedirect(route('login'));
}
public function test_random_redirects_when_episode_exists(): void
{
$hentai = Hentai::factory()->create();
$studio = Studios::factory()->create();
$episode = Episode::factory()->create([
'hentai_id' => $hentai->id,
'studios_id' => $studio->id,
]);
$this->get('/random')->assertRedirect(route('hentai.index', $episode->slug));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Feature;
use Tests\RefreshDatabase;
use Tests\TestCase;
class StatsPageTest extends TestCase
{
use RefreshDatabase;
public function test_stats_page_renders_with_empty_database(): void
{
$this->get('/stats')->assertOk();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase as BaseRefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* RefreshDatabase variant that provisions the test database from the committed
* MySQL/MariaDB schema dump (database/schema/mysql-schema.sql) instead of
* running the app's incomplete / data-dependent migrations.
*
* Refresh the dump after schema changes with: php artisan schema:dump
*/
trait RefreshDatabase
{
use BaseRefreshDatabase;
/**
* Import the committed schema dump. It is idempotent: it drops and
* recreates every table, so it also works when the test database already
* exists from a previous run.
*/
protected function migrateDatabases()
{
DB::unprepared(
file_get_contents(database_path('schema/mysql-schema.sql'))
);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Tests\Support;
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
use AltchaOrg\Altcha\Altcha;
use AltchaOrg\Altcha\CreateChallengeOptions;
use AltchaOrg\Altcha\SolveChallengeOptions;
/**
* Builds a valid altcha payload for tests using the fixed ALTCHA_HMAC_KEY,
* mirroring the payload shape produced by the browser widget (base64 JSON with
* a challenge array and a solution array).
*/
final class AltchaPayload
{
public static function valid(?int $counter = null): string
{
$pbkdf2 = new Pbkdf2;
$altcha = new Altcha(
hmacSignatureSecret: config('captcha.hmac_key'),
);
$counter ??= random_int(1, 100);
$challenge = $altcha->createChallenge(new CreateChallengeOptions(
algorithm: $pbkdf2,
cost: 5000,
counter: $counter,
expiresAt: time() + 600,
));
$solution = $altcha->solveChallenge(new SolveChallengeOptions(
challenge: $challenge,
algorithm: $pbkdf2,
start: $counter,
step: 1,
timeout: 5,
));
return base64_encode(json_encode([
'challenge' => $challenge->toArray(),
'solution' => $solution->toArray(),
]));
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Tests\Unit;
use App\Services\CdnPathValidator;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CdnPathValidatorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
config([
'hstream.download_domain_4k' => ['https://dl4k-a.test', 'https://dl4k-b.test'],
]);
}
public function test_fhd_uses_4k_domains_and_hentai_1080p_prefix(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
]);
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertTrue($result['valid']);
$this->assertSame(12345, $result['size']);
$this->assertSame([
'https://dl4k-a.test' => ['hentai-1080p/2026/Title/E01.mkv' => true],
'https://dl4k-b.test' => ['hentai-1080p/2026/Title/E01.mkv' => true],
], $result['mirrors']);
Http::assertSent(fn ($request) => str_contains($request->url(), 'https://dl4k-a.test/check/'));
}
public function test_uhd_uses_download_domain_4k_and_hentai_prefix(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]),
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]),
]);
$result = app(CdnPathValidator::class)->validateDownload('UHD', '2026/Title/E01.mkv');
$this->assertTrue($result['valid']);
$this->assertArrayHasKey('hentai/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']);
$this->assertArrayNotHasKey('hentai-1080p/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']);
}
public function test_missing_on_one_mirror_is_invalid(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
'https://dl4k-b.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
]);
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertFalse($result['valid']);
$this->assertNull($result['size']);
$this->assertTrue($result['mirrors']['https://dl4k-a.test']['hentai-1080p/2026/Title/E01.mkv']);
$this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']);
}
public function test_connection_error_is_invalid(): void
{
Http::fake(function ($request) {
if (str_starts_with($request->url(), 'https://dl4k-a.test/')) {
return Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]);
}
throw new ConnectionException('Connection timed out', 0);
});
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertFalse($result['valid']);
$this->assertNull($result['size']);
$this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']);
}
public function test_stream_checks_base_directory_and_first_manifest(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
]);
$result = app(CdnPathValidator::class)->validateStream('2026/Title', 3);
$this->assertTrue($result['valid']);
$this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']);
$this->assertArrayHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
Http::assertSentCount(4);
}
public function test_stream_can_target_specific_episode_manifest(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
]);
$result = app(CdnPathValidator::class)->validateStream('2026/Title', 1, false, 2);
$this->assertTrue($result['valid']);
$this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']);
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
$this->assertSame(4, count(Http::recorded()));
}
public function test_results_are_cached_for_short_period(): void
{
Http::fake([
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
]);
$validator = app(CdnPathValidator::class);
$first = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
$second = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertTrue($first['valid']);
$this->assertTrue($second['valid']);
Http::assertSentCount(2);
}
public function test_fresh_check_bypasses_cache(): void
{
$valid = true;
Http::fake(function ($request) use (&$valid) {
return Http::response(
$valid ? ['valid' => true, 'type' => 'file', 'size' => 12345] : ['valid' => false, 'error' => 'File not found!'],
$valid ? 200 : 404
);
});
$validator = app(CdnPathValidator::class);
$cached = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertTrue($cached['valid']);
$this->assertCount(2, Http::recorded());
// The CDN result changes within the cache window, but a non-fresh
// check still serves the cached valid result.
$valid = false;
$stale = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertTrue($stale['valid']);
$this->assertCount(2, Http::recorded());
// A fresh check bypasses the cache and overwrites the stored result.
$fresh = $validator->validateDownload('FHD', '2026/Title/E01.mkv', true);
$this->assertFalse($fresh['valid']);
$this->assertCount(4, Http::recorded());
// The fresh (invalid) result is now what subsequent cached checks serve.
$after = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
$this->assertFalse($after['valid']);
$this->assertCount(4, Http::recorded());
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit;
use App\Jobs\DiscordReleaseNotification;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class DiscordReleaseNotificationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Http::fake();
config([
'discord-alerts.webhook_urls.default' => 'https://discord.com/api/webhooks/test/123',
]);
}
public function test_it_does_not_send_outside_production(): void
{
(new DiscordReleaseNotification('some-slug', 'release'))->handle();
Http::assertNothingSent();
}
public function test_it_sends_in_production(): void
{
app()->instance('env', 'production');
(new DiscordReleaseNotification('some-slug', 'release'))->handle();
Http::assertSent(function ($request) {
return str_contains($request->url(), 'https://discord.com/api/webhooks/test/123');
});
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
'resources/js/player-data.js', 'resources/js/player-data.js',
'resources/js/player.js', 'resources/js/player.js',
'resources/js/playlist.js', 'resources/js/playlist.js',
'resources/js/upload.js', 'resources/js/admin-release.js',
'resources/js/user-blacklist.js', 'resources/js/user-blacklist.js',
'resources/js/admin-edit.js', 'resources/js/admin-edit.js',
'resources/js/admin-subtitles.js', 'resources/js/admin-subtitles.js',