Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c37df755 | |||
| 6a9d3b25bf | |||
| 75b98de746 | |||
| eaf48276f0 | |||
| 2e3918def4 |
@@ -11,7 +11,6 @@
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.testing
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
@@ -22,5 +21,3 @@ yarn-error.log
|
||||
/.idea
|
||||
/.vscode
|
||||
.directory
|
||||
/.kilo
|
||||
/.cursor
|
||||
@@ -1,209 +0,0 @@
|
||||
<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,6 +30,34 @@ class EpisodeController extends Controller
|
||||
$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
|
||||
*/
|
||||
|
||||
@@ -3,10 +3,33 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -14,4 +37,55 @@ class ReleaseController extends Controller
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,14 @@ class StreamController extends Controller
|
||||
// Playlist
|
||||
if ($request->has('playlist')) {
|
||||
// Get and check if playlist exists
|
||||
$playlist = Playlist::withCount('episodes')->where('id', $request->input('playlist'))->firstOrFail();
|
||||
$playlist = Playlist::where('id', $request->input('playlist'))->firstOrFail();
|
||||
|
||||
// Check if episode is in playlist
|
||||
$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
|
||||
if ($playlist->is_private && (Auth::guest() || (! Auth::guest() && Auth::user()->id != $playlist->user_id))) {
|
||||
abort(404);
|
||||
@@ -72,6 +75,7 @@ class StreamController extends Controller
|
||||
'studioEpisodes' => $studioEpisodes,
|
||||
'gallery' => $gallery,
|
||||
'playlist' => $playlist,
|
||||
'playlistEpisodes' => $playlistEpisodes,
|
||||
'popularWeekly' => CacheHelper::getPopularWeekly(),
|
||||
'isMobile' => $isMobile,
|
||||
]);
|
||||
|
||||
@@ -31,11 +31,6 @@ class DiscordReleaseNotification implements ShouldQueue
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Discord notifications must only ever be sent in production.
|
||||
if (! app()->isProduction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($this->messageType) {
|
||||
case 'release':
|
||||
DiscordAlert::message('<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/'.$this->slug);
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistEpisode;
|
||||
use App\Services\PlaylistService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
@@ -19,13 +18,14 @@ class PlaylistOverview extends Component
|
||||
protected PlaylistService $playlistService;
|
||||
|
||||
#[Url(history: true)]
|
||||
public string $search = '';
|
||||
public $search;
|
||||
|
||||
#[Url(history: true)]
|
||||
public int $perPage = 25;
|
||||
public int $pagination = 25;
|
||||
|
||||
public Playlist $playlist;
|
||||
|
||||
public Collection $playlistEpisodes;
|
||||
|
||||
public bool $editingName = false;
|
||||
|
||||
public string $editingPlaylistName = '';
|
||||
@@ -37,56 +37,35 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function mount($playlist_id)
|
||||
{
|
||||
$this->playlist = Playlist::withCount('episodes')->with('user')->findOrFail($playlist_id);
|
||||
$this->playlist = Playlist::with(['episodes.episode'])->findOrFail($playlist_id);
|
||||
|
||||
$this->sanitizePerPage();
|
||||
// Set position if null
|
||||
$this->playlist->episodes->each(function ($item, $index) {
|
||||
if ($item->position === null) {
|
||||
$item->position = $index + 1;
|
||||
$item->save();
|
||||
}
|
||||
});
|
||||
|
||||
$this->repairNullPositions();
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function updatingSearch(): void
|
||||
public function refreshEpisodes()
|
||||
{
|
||||
$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;
|
||||
$this->playlistEpisodes = $this->playlist->episodes()->orderBy('position')->with('episode')->get();
|
||||
}
|
||||
|
||||
public function moveUp($episodeId)
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$episode = PlaylistEpisode::find($episodeId);
|
||||
|
||||
if (! $episode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
|
||||
->where('position', '<', $episode->position)
|
||||
->orderBy('position', 'desc')
|
||||
@@ -95,20 +74,21 @@ class PlaylistOverview extends Component
|
||||
if ($above) {
|
||||
$this->playlistService->swapPositions($episode, $above);
|
||||
}
|
||||
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function moveDown($episodeId)
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$episode = PlaylistEpisode::find($episodeId);
|
||||
|
||||
if (! $episode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
|
||||
->where('position', '>', $episode->position)
|
||||
->orderBy('position')
|
||||
@@ -117,28 +97,28 @@ class PlaylistOverview extends Component
|
||||
if ($below) {
|
||||
$this->playlistService->swapPositions($episode, $below);
|
||||
}
|
||||
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function remove($episodeId)
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistEpisode::find($episodeId)?->delete();
|
||||
$this->playlistService->reorderPositions($this->playlist);
|
||||
$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);
|
||||
}
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function editName()
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +134,7 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function updateName()
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,7 +152,7 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function toggleVisibility()
|
||||
{
|
||||
if (! $this->isOwner()) {
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,39 +160,13 @@ class PlaylistOverview extends Component
|
||||
'is_private' => ! $this->playlist->is_private,
|
||||
]);
|
||||
|
||||
$this->playlist->loadCount('episodes');
|
||||
$this->playlist->refresh();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.playlist-overview', [
|
||||
'episodes' => $this->episodes,
|
||||
'firstEpisode' => $this->firstEpisode,
|
||||
'isOwner' => $this->isOwner(),
|
||||
'query' => $this->search,
|
||||
]);
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<?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]));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
@@ -55,30 +54,11 @@ class Playlists extends Component
|
||||
->withCount('episodes')
|
||||
->having('episodes_count', '>', 1)
|
||||
->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)
|
||||
->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', [
|
||||
'playlists' => $playlists,
|
||||
'totalPlaylists' => $stats['playlists'],
|
||||
'totalEpisodes' => $stats['episodes'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ class Downloads extends Model
|
||||
'episode_id',
|
||||
'type',
|
||||
'url',
|
||||
'size',
|
||||
'validated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,22 +2,12 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Gallery extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $table = 'gallery';
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* Belongs To Episode.
|
||||
*/
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Playlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PlaylistEpisode extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Indicates If The Model Should Be Timestamped.
|
||||
*
|
||||
|
||||
@@ -14,15 +14,9 @@ class CommentPresenter
|
||||
$this->comment = $comment;
|
||||
}
|
||||
|
||||
public function markdownBody(?int $limit = null)
|
||||
public function markdownBody()
|
||||
{
|
||||
$body = $this->comment->body;
|
||||
|
||||
if ($limit !== null) {
|
||||
$body = Str::limit($body, $limit);
|
||||
}
|
||||
|
||||
return Str::of($body)->markdown([
|
||||
return Str::of($this->comment->body)->markdown([
|
||||
'html_input' => 'strip',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -33,28 +33,4 @@ 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,14 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\ModLog;
|
||||
use App\Models\Studios;
|
||||
use App\Models\ModLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class EpisodeService
|
||||
{
|
||||
@@ -32,6 +30,39 @@ class EpisodeService
|
||||
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
|
||||
{
|
||||
$tags = json_decode($request->input('tags'));
|
||||
@@ -140,60 +171,19 @@ 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
|
||||
{
|
||||
if (! $request->hasFile("episodecover{$episodeNumber}")) {
|
||||
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
|
||||
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
|
||||
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
|
||||
}
|
||||
|
||||
// Encode and save cover image
|
||||
Image::read($file->getRealPath())
|
||||
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath())
|
||||
->cover(268, 394)
|
||||
->encode(new WebpEncoder)
|
||||
->save(Storage::disk('public')->path($episode->cover_url));
|
||||
|
||||
@@ -6,11 +6,9 @@ use App\Models\Episode;
|
||||
use App\Models\Gallery;
|
||||
use App\Models\Hentai;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class GalleryService
|
||||
{
|
||||
@@ -19,26 +17,17 @@ class GalleryService
|
||||
$galleryInputNumber = $override ? 1 : $episodeNumber;
|
||||
|
||||
if ($request->hasFile('episodegallery'.$galleryInputNumber)) {
|
||||
$this->saveGalleryFiles($hentai, $episode, $episodeNumber, $request->file('episodegallery'.$galleryInputNumber));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->deleteOldGallery($episode);
|
||||
|
||||
$this->createGalleryFolder($hentai);
|
||||
$this->createGalleryFolder($hentai);
|
||||
|
||||
$counter = 0;
|
||||
foreach ($files as $file) {
|
||||
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
|
||||
$this->saveGalleryImage($gallery, $file);
|
||||
$counter += 1;
|
||||
$counter = 0;
|
||||
foreach ($request->file('episodegallery'.$galleryInputNumber) as $file) {
|
||||
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
|
||||
$this->saveGalleryImage($gallery, $file);
|
||||
$counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +51,7 @@ class GalleryService
|
||||
return $gallery;
|
||||
}
|
||||
|
||||
private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void
|
||||
private function saveGalleryImage(Gallery $gallery, $sourceImage): void
|
||||
{
|
||||
Image::read($sourceImage->getRealPath())
|
||||
->cover(1920, 1080)
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
"cursor"
|
||||
],
|
||||
"cloud": false,
|
||||
"guidelines": true,
|
||||
"mcp": true,
|
||||
"nightwatch": false,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"laravel-best-practices",
|
||||
"scout-development",
|
||||
"socialite-development",
|
||||
"livewire-development",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
@@ -34,7 +34,6 @@
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^3.16",
|
||||
"fakerphp/faker": "^1.24.0",
|
||||
"laravel/boost": "^2.4",
|
||||
"laravel/breeze": "^2.3",
|
||||
"laravel/pint": "^1.18",
|
||||
"mockery/mockery": "^1.4.4",
|
||||
|
||||
Generated
+1
-278
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "636d4675deabe7605859a7873be3d200",
|
||||
"content-hash": "bfeb75482defef9826c45252382f7e6d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "altcha-org/altcha",
|
||||
@@ -10138,72 +10138,6 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v2.4.2",
|
||||
@@ -10265,80 +10199,6 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v1.29.1",
|
||||
@@ -10407,67 +10267,6 @@
|
||||
},
|
||||
"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",
|
||||
"version": "1.6.12",
|
||||
@@ -12779,82 +12578,6 @@
|
||||
],
|
||||
"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",
|
||||
"version": "1.3.1",
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<?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',
|
||||
];
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,548 +0,0 @@
|
||||
/*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,8 +2,6 @@
|
||||
|
||||
return [
|
||||
'home' => 'Startseite',
|
||||
'browse' => 'Stöbern',
|
||||
'community' => 'Community',
|
||||
'search' => 'Suche',
|
||||
'public-playlists' => 'Öffentliche Playlisten',
|
||||
'downloads' => 'Downloads',
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
|
||||
return [
|
||||
'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',
|
||||
'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.',
|
||||
'play' => 'Abspielen',
|
||||
'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,8 +2,6 @@
|
||||
|
||||
return [
|
||||
'home' => 'Home',
|
||||
'browse' => 'Browse',
|
||||
'community' => 'Community',
|
||||
'search' => 'Search',
|
||||
'public-playlists' => 'Public Playlists',
|
||||
'downloads' => 'Downloads',
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
|
||||
return [
|
||||
'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',
|
||||
'create-on-personal-page' => 'You can create one in your personal playlists page.',
|
||||
'play' => 'Play',
|
||||
'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,8 +2,6 @@
|
||||
|
||||
return [
|
||||
'home' => 'Accueil',
|
||||
'browse' => 'Parcourir',
|
||||
'community' => 'Communauté',
|
||||
'search' => 'Recherche',
|
||||
'public-playlists' => 'Playlists publiques',
|
||||
'downloads' => 'Téléchargements',
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
|
||||
return [
|
||||
'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',
|
||||
'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.',
|
||||
'play' => 'Lire',
|
||||
'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 ?',
|
||||
];
|
||||
|
||||
Generated
+373
-454
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,7 @@
|
||||
"chart.js": "^4.5.0",
|
||||
"dashjs": "^5.0.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr": "^3.8.4",
|
||||
"tw-elements": "^1.1.0",
|
||||
"vidstack": "^1.12.13"
|
||||
}
|
||||
|
||||
+2
-5
@@ -19,15 +19,12 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="BCRYPT_ROUNDS" value="10"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="array"/>
|
||||
<env name="DB_CONNECTION" value="mysql"/>
|
||||
<env name="DB_DATABASE" value="hstream_testing"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="SCOUT_DRIVER" value="collection"/>
|
||||
<env name="ALTCHA_HMAC_KEY" value="testing-altcha-hmac-key"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
+1
-70
@@ -1,4 +1,5 @@
|
||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||
@import './player.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -8,47 +9,6 @@
|
||||
--breakpoint-xs: 30rem;
|
||||
}
|
||||
|
||||
/* Player */
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded="true"] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Player Engagement Heatmap, overlays the progress bar track */
|
||||
.plyr__progress__heatmap {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 40%;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.plyr__progress__heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Player Ambient */
|
||||
.decoy {
|
||||
position: absolute;
|
||||
@@ -68,35 +28,6 @@ input:checked~.dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
input:checked~.theme-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
#plyr__time_skip {
|
||||
background: #c61e54;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
left: 50%;
|
||||
min-width: 60px;
|
||||
width: min-content;
|
||||
max-width: 100px;
|
||||
max-height: 90px;
|
||||
opacity: 0;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
transform: translate(-50%, -50%);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transition: 1s;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-shadow: 0px 0px 45px #000000;
|
||||
}
|
||||
|
||||
/* DL Button Glow */
|
||||
.hover\:glow:hover {
|
||||
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
/* ============================================================
|
||||
HStream Custom Video Player
|
||||
Dark-theme-first with rose-red accents (#c61e54)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Player Wrapper ---- */
|
||||
.hstream-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
direction: ltr;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hstream-player * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- Video Element ---- */
|
||||
.hstream-player__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* ---- Poster Overlay ---- */
|
||||
.hstream-player__poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster--hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ---- Large Play Overlay ---- */
|
||||
.hstream-player__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__play-overlay--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(198, 30, 84, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.15s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 0 30px rgba(198, 30, 84, 0.4), 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(225, 29, 72, 0.9);
|
||||
box-shadow: 0 0 40px rgba(198, 30, 84, 0.6), 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ---- Controls Container ---- */
|
||||
.hstream-player__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: linear-gradient(to top, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.75) 60%, transparent);
|
||||
padding: 40px 12px 10px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.hstream-player__controls--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Progress Section (full width row) ---- */
|
||||
.hstream-player__progress-wrapper {
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: height 0.15s ease, margin-top 0.15s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover,
|
||||
.hstream-player__progress--dragging {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hstream-player__progress--dragging .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.hstream-player__progress-buffer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transition: width 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hstream-player__progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(to right, #c61e54, #e11d48);
|
||||
transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #c61e54;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.15s ease;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(198, 30, 84, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover .hstream-player__progress-thumb,
|
||||
.hstream-player__progress-wrapper--active .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* ---- Time Tooltip ---- */
|
||||
.hstream-player__time-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
color: #fff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hstream-player__time-tooltip--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Thumbnail Preview ---- */
|
||||
.hstream-player__thumbnail-preview {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 16px);
|
||||
transform: translateX(-50%);
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-img {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-time {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.hstream-player__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hstream-player__button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.hstream-player__button--active {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ---- Time Display ---- */
|
||||
.hstream-player__time-display {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.hstream-player__time-separator {
|
||||
opacity: 0.5;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* ---- Volume Wrapper ---- */
|
||||
.hstream-player__volume-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.hstream-player__volume-wrapper:hover .hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.hstream-player--mobile .hstream-player__volume-slider-container {
|
||||
width: 80px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
outline: none !important;
|
||||
cursor: pointer !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
margin-top: -5px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
/* ---- Spacer ---- */
|
||||
.hstream-player__spacer {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
/* ---- Settings Menu ---- */
|
||||
.hstream-player__menu-container {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 80px;
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
background: rgba(20, 20, 20, 0.70);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__menu-container--open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel--active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item-radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio {
|
||||
border-color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-value {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item > span:first-of-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-value {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-badge {
|
||||
display: inline-block;
|
||||
background: #c61e54;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hstream-player__menu-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ---- Skip Overlay (Mobile) ---- */
|
||||
.hstream-player__skip-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(198, 30, 84, 0.7);
|
||||
border-radius: 50%;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay--visible {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ---- Loading Spinner ---- */
|
||||
.hstream-player__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__loading--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: #c61e54;
|
||||
border-radius: 50%;
|
||||
animation: hstream-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hstream-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- Player Switcher Toggle ---- */
|
||||
.player-switcher {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(10, 10, 10, 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
padding: 6px 4px 6px 14px;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.aspect-\[16\/9\]:hover .player-switcher {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.player-switcher--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.player-switcher__label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-switcher__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-switcher__btn:hover {
|
||||
background: rgba(198, 30, 84, 0.2);
|
||||
border-color: rgba(198, 30, 84, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__btn i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.player-switcher__btn--active {
|
||||
background: rgba(198, 30, 84, 0.25);
|
||||
border-color: rgba(198, 30, 84, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
box-shadow: 0 0 6px rgba(198, 30, 84, 0.6);
|
||||
}
|
||||
|
||||
/* ---- Fullscreen (remove rounded borders) ---- */
|
||||
.hstream-player:fullscreen,
|
||||
.hstream-player:-webkit-full-screen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player:fullscreen .hstream-player__video,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__video,
|
||||
.hstream-player:fullscreen .hstream-player__poster,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__poster,
|
||||
.hstream-player:fullscreen .hstream-player__controls,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__controls {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- Idle State (hide controls & cursor) ---- */
|
||||
.hstream-player--idle {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.hstream-player--idle .hstream-player__controls {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Mobile Responsive ---- */
|
||||
@media (max-width: 767px) {
|
||||
.hstream-player {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__controls {
|
||||
padding: 36px 4px 8px 4px;
|
||||
gap: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-container {
|
||||
right: 8px;
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-wrapper {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.hstream-player__time-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Plyr Player Overrides — pink accent, rounded corners, skip overlay
|
||||
============================================================ */
|
||||
.plyr--full-ui input[type='range'] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded='true'] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Plyr Engagement Heatmap, overlays the progress bar track */
|
||||
.plyr__progress__heatmap {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 40%;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.plyr__progress__heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Plyr Mobile Double-tap Skip Overlay */
|
||||
#plyr__time_skip {
|
||||
background: #c61e54;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
left: 50%;
|
||||
min-width: 60px;
|
||||
width: min-content;
|
||||
max-width: 100px;
|
||||
max-height: 90px;
|
||||
opacity: 0;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
transform: translate(-50%, -50%);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transition: 1s;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-shadow: 0px 0px 45px #000000;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
// Plyr Fallback Player
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
import * as dashjs from 'dashjs';
|
||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||
|
||||
import { initMobileWidescreen } from './player-mobile';
|
||||
import { mobileDoubleClick } from './player-mobile';
|
||||
import { playNextPlaylistVideo } from './playlist';
|
||||
import { addVideoTracks } from './player-data';
|
||||
import { addSubtitleTracks } from './player-data';
|
||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||
import { isIOS } from './detect-ios';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||
import { renderHeatmap } from './player/player-heatmap';
|
||||
|
||||
var player = null;
|
||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||
var dashSupported = dashjs.supportsMediaSource();
|
||||
var apiResponse = {};
|
||||
var volume = 0.5;
|
||||
var muted = false;
|
||||
var captions = true;
|
||||
var lastTime = 0.0;
|
||||
var streamServer = '';
|
||||
var streamServers = [];
|
||||
var streamServerIndex = 0;
|
||||
var streamServerCount = 0;
|
||||
var ambientMode = true;
|
||||
var serverFallback = false;
|
||||
var saveInterval;
|
||||
var watchTracked = false;
|
||||
var subtitleInstance = null;
|
||||
|
||||
function trackWatchTime() {
|
||||
if (watchTracked) return;
|
||||
var video = document.getElementsByTagName('video')[0];
|
||||
if (video && video.currentTime >= 10) {
|
||||
watchTracked = true;
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
window.axios.post('/watched/track', {
|
||||
episode_id: episodeId
|
||||
}).then(function () {
|
||||
console.log('Watch tracked for episode ' + episodeId);
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to track watch: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var controls = [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'duration',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'fullscreen',
|
||||
];
|
||||
|
||||
if (localStorage.hstreamVolume) {
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamCaptions) {
|
||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamMuted) {
|
||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamServerFallback) {
|
||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||
}
|
||||
|
||||
if (!av1Supported) {
|
||||
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function initDash(data, player) {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(video, el.src, true);
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCanvasDimension(canvas, video) {
|
||||
canvas.height = video.offsetHeight;
|
||||
canvas.width = video.offsetWidth;
|
||||
}
|
||||
|
||||
function paintStaticVideo(ctx, video) {
|
||||
if (localStorage.theme == 'light') {
|
||||
return;
|
||||
}
|
||||
if (!ambientMode) {
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||
}
|
||||
|
||||
function toggleAmbientMode() {
|
||||
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||
if (ambientMode) {
|
||||
ambientMode = false;
|
||||
localStorage.ambientMode = 'false';
|
||||
setCanvasDimension(canvas, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||
} else {
|
||||
ambientMode = true;
|
||||
localStorage.ambientMode = 'true';
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAsiaServer() {
|
||||
if (serverFallback) {
|
||||
serverFallback = false;
|
||||
localStorage.hstreamServerFallback = 'false';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||
streamServers = apiResponse.stream_domains;
|
||||
} else {
|
||||
serverFallback = true;
|
||||
localStorage.hstreamServerFallback = 'true';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
}
|
||||
|
||||
function initSubtitles(lang) {
|
||||
if (isIOS()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
subtitleInstance.dispose();
|
||||
}
|
||||
|
||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
|
||||
if (lang != 'en') {
|
||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||
} else {
|
||||
newSubUrl += 'eng.ass';
|
||||
}
|
||||
|
||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
if (lang == 'hi') {
|
||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||
}
|
||||
|
||||
var options = {
|
||||
video: document.getElementsByTagName('video')[0],
|
||||
subUrl: newSubUrl,
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||
fonts: [subFont],
|
||||
renderMode: 'wasm-blend',
|
||||
};
|
||||
|
||||
subtitleInstance = new SubtitlesOctopus(options);
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
player = new Plyr('#player', {
|
||||
controls,
|
||||
quality: {
|
||||
default: 720,
|
||||
options: [2161, 2160, 1081, 1080, 720]
|
||||
},
|
||||
i18n: {
|
||||
qualityLabel: {
|
||||
2161: '2160p48',
|
||||
2160: '2160p',
|
||||
1081: '1080p48',
|
||||
1080: '1080p',
|
||||
720: '720p'
|
||||
},
|
||||
qualityBadge: {
|
||||
2161: 'UHD@48',
|
||||
1081: 'FHD@48',
|
||||
1080: 'FHD',
|
||||
},
|
||||
},
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||
});
|
||||
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
|
||||
player.source = {
|
||||
type: 'video',
|
||||
title: apiResponse.title,
|
||||
poster: apiResponse.poster,
|
||||
previewThumbnails: {
|
||||
enabled: true,
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||
},
|
||||
sources: data,
|
||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||
};
|
||||
|
||||
player.volume = volume;
|
||||
player.muted = muted;
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
});
|
||||
|
||||
initDash(data, player);
|
||||
}
|
||||
|
||||
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log('Stopped video, because user didn\'t click play.');
|
||||
}
|
||||
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
startEngagementTracking(episodeId);
|
||||
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
player.on('seeked', () => {
|
||||
paintStaticVideo(ctx, video);
|
||||
if (player.currentTime > 0) {
|
||||
lastTime = player.currentTime;
|
||||
}
|
||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
if (player.paused) {
|
||||
paintStaticVideo(ctx, video);
|
||||
}
|
||||
});
|
||||
|
||||
player.on('captionsenabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
||||
localStorage.setItem('hstreamCaptions', 'true');
|
||||
console.log('Set Captions Status to Local Storage: true');
|
||||
});
|
||||
|
||||
player.on('captionsdisabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
||||
localStorage.setItem('hstreamCaptions', 'false');
|
||||
console.log('Set Captions Status to Local Storage: false');
|
||||
});
|
||||
|
||||
player.on('volumechange', () => {
|
||||
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||
});
|
||||
|
||||
player.on('ended', () => {
|
||||
playNextPlaylistVideo();
|
||||
});
|
||||
|
||||
player.on('timeupdate', () => {
|
||||
trackWatchTime();
|
||||
});
|
||||
|
||||
player.on('languagechange', (event) => {
|
||||
let lang = event.detail.plyr.captions.language;
|
||||
|
||||
console.log('Subtitle Event ' + lang);
|
||||
initSubtitles(lang);
|
||||
});
|
||||
|
||||
function playerPlayTemp() {
|
||||
clickedPlay = true;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
||||
play.addEventListener('click', playerPlayTemp)
|
||||
);
|
||||
|
||||
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
||||
|
||||
initMobileWidescreen();
|
||||
|
||||
setTimeout(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const time = parseInt(params.get('t'));
|
||||
if (!isNaN(time)) {
|
||||
player.currentTime = time;
|
||||
console.log('Skipping to ' + time);
|
||||
}
|
||||
if (lastTime > 0) {
|
||||
player.currentTime = lastTime;
|
||||
console.log('Skipping to ' + lastTime);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
player.on('ready', () => {
|
||||
mobileDoubleClick(player);
|
||||
|
||||
const video = document.querySelector('video');
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
if (video && video.duration) {
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
} else if (video) {
|
||||
video.addEventListener('loadedmetadata', function onMeta() {
|
||||
video.removeEventListener('loadedmetadata', onMeta);
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
||||
if (settingElements.length == 3) {
|
||||
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
||||
|
||||
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
||||
if (settingNodes.length == 4) {
|
||||
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
||||
}
|
||||
|
||||
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
||||
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
||||
let serverSelects = document.getElementsByClassName('change_server');
|
||||
for (let i = 0; i < serverSelects.length; i++) {
|
||||
serverSelects[i].addEventListener('click', function () {
|
||||
streamServerIndex = Number(this.value);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveInterval = setInterval(function () {
|
||||
lastTime = player.currentTime;
|
||||
console.log('Last Player Position: ' + lastTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
export function initPlyrPlayer(episodeId) {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: episodeId
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
initPlayer();
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
return player;
|
||||
}
|
||||
+190
-386
@@ -1,30 +1,17 @@
|
||||
// Plyr Player
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
// Vidstack Player
|
||||
// HStream Custom Video Player
|
||||
import 'vidstack/player/styles/default/theme.css';
|
||||
import 'vidstack/player/styles/default/layouts/video.css';
|
||||
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
|
||||
|
||||
// Dash Support
|
||||
import * as dashjs from 'dashjs';
|
||||
|
||||
// Subtitle Support
|
||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||
|
||||
// Custom JS
|
||||
import { initMobileWidescreen } from './player-mobile';
|
||||
import { mobileDoubleClick } from './player-mobile'
|
||||
import { HStreamPlayer } from './player/player-core';
|
||||
import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/player-mobile';
|
||||
import { playNextPlaylistVideo } from './playlist';
|
||||
import { addVideoTracks } from './player-data';
|
||||
import { addSubtitleTracks } from './player-data';
|
||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||
import { addVideoTracks, addSubtitleTracks } from './player/player-data';
|
||||
import { isIOS } from './detect-ios';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player-engagement';
|
||||
import { renderHeatmap } from './player-heatmap';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||
import { renderHeatmap } from './player/player-heatmap';
|
||||
|
||||
// Variables
|
||||
var player = null;
|
||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||
var dashSupported = dashjs.supportsMediaSource();
|
||||
@@ -35,23 +22,23 @@ var captions = true;
|
||||
var lastTime = 0.0;
|
||||
var streamServer = '';
|
||||
var streamServers = [];
|
||||
var fallbackServers = [];
|
||||
var streamServerIndex = 0;
|
||||
var streamServerCount = 0;
|
||||
var ambientMode = true;
|
||||
var serverFallback = false;
|
||||
var saveInterval;
|
||||
var watchTracked = false;
|
||||
var subtitleInstance = null;
|
||||
|
||||
// Track that the user watched at least 10 seconds of the video
|
||||
function trackWatchTime() {
|
||||
if (watchTracked) return;
|
||||
var video = document.getElementsByTagName('video')[0];
|
||||
if (video && video.currentTime >= 10) {
|
||||
var videoEl = document.getElementsByTagName('video')[0];
|
||||
if (videoEl && videoEl.currentTime >= 10) {
|
||||
watchTracked = true;
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
window.axios.post('/watched/track', {
|
||||
episode_id: episodeId
|
||||
}).then(function (response) {
|
||||
}).then(function () {
|
||||
console.log('Watch tracked for episode ' + episodeId);
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to track watch: ' + error);
|
||||
@@ -59,119 +46,24 @@ function trackWatchTime() {
|
||||
}
|
||||
}
|
||||
|
||||
var subtitleInstance = null;
|
||||
|
||||
var controls = [
|
||||
'play-large', // The large play button in the center
|
||||
'play', // Play/pause playback
|
||||
'progress', // The progress bar and scrubber for playback and buffering
|
||||
'current-time', // The current time of playback
|
||||
'duration', // The full duration of the media
|
||||
'mute', // Toggle mute
|
||||
'volume', // Volume control
|
||||
'captions', // Toggle captions
|
||||
'settings', // Settings menu
|
||||
'fullscreen', // Toggle fullscreen
|
||||
];
|
||||
|
||||
// Load Volume from LocalStorage
|
||||
if (localStorage.hstreamVolume) {
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume'));
|
||||
if (!isNaN(volume)) volume = Math.max(0, Math.min(1, volume));
|
||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||
}
|
||||
|
||||
// Load Captions from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||
captions = (localStorage.getItem('hstreamCaptions') === 'true');
|
||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||
}
|
||||
|
||||
// Load Muted from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||
if (localStorage.hstreamMuted) {
|
||||
muted = (localStorage.getItem('hstreamMuted') === 'true');
|
||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||
}
|
||||
|
||||
// Asia Server Fallback
|
||||
if (localStorage.hstreamServerFallback) {
|
||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||
}
|
||||
|
||||
// Alert User when AV1 is not supported
|
||||
if (!av1Supported) {
|
||||
document.getElementById("av1-unsupported").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function initDash(data, player) {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(video, el.src, true);
|
||||
// Expose player and dash so they can be used from the console
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCanvasDimension(canvas, video) {
|
||||
canvas.height = video.offsetHeight;
|
||||
canvas.width = video.offsetWidth;
|
||||
}
|
||||
|
||||
function paintStaticVideo(ctx, video) {
|
||||
if (localStorage.theme == 'light') {
|
||||
return;
|
||||
}
|
||||
if (!ambientMode) {
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||
}
|
||||
|
||||
function toggleAmbientMode() {
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
if (ambientMode) {
|
||||
ambientMode = false;
|
||||
localStorage.ambientMode = 'false';
|
||||
setCanvasDimension(canvas, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||
} else {
|
||||
ambientMode = true;
|
||||
localStorage.ambientMode = 'true';
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAsiaServer() {
|
||||
if (serverFallback) {
|
||||
serverFallback = false;
|
||||
localStorage.hstreamServerFallback = 'false';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||
streamServers = apiResponse.stream_domains;
|
||||
} else {
|
||||
serverFallback = true;
|
||||
localStorage.hstreamServerFallback = 'true';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function initSubtitles(lang) {
|
||||
@@ -179,32 +71,28 @@ function initSubtitles(lang) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispose old instance
|
||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
subtitleInstance.dispose();
|
||||
}
|
||||
|
||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
|
||||
if (lang != 'en') {
|
||||
if (lang !== 'en') {
|
||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||
}
|
||||
else {
|
||||
newSubUrl += 'eng.ass'
|
||||
} else {
|
||||
newSubUrl += 'eng.ass';
|
||||
}
|
||||
|
||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
// Hindi font
|
||||
if (lang == 'hi') {
|
||||
var subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
if (lang === 'hi') {
|
||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
var options = {
|
||||
video: document.getElementsByTagName('video')[0], // HTML5 video element
|
||||
subUrl: newSubUrl, // Link to subtitles
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js"
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker
|
||||
video: document.getElementsByTagName('video')[0],
|
||||
subUrl: newSubUrl,
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||
fonts: [subFont],
|
||||
renderMode: 'wasm-blend',
|
||||
};
|
||||
@@ -212,237 +100,154 @@ function initSubtitles(lang) {
|
||||
subtitleInstance = new SubtitlesOctopus(options);
|
||||
}
|
||||
|
||||
function initPlayerQualityChange(data) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', function () {
|
||||
initDash(data);
|
||||
});
|
||||
initDash(data);
|
||||
}
|
||||
}
|
||||
|
||||
function initDash(data) {
|
||||
var videoEl = document.querySelector('video');
|
||||
var quality = player.quality;
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === quality) {
|
||||
var dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(videoEl, el.src, true);
|
||||
window.dash = dash;
|
||||
player.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
player = new Plyr('#player', {
|
||||
controls,
|
||||
quality: {
|
||||
default: 720,
|
||||
options: [2161, 2160, 1081, 1080, 720]
|
||||
var videoEl = document.querySelector('#player');
|
||||
var container = videoEl.parentElement;
|
||||
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
|
||||
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
|
||||
player = new HStreamPlayer({
|
||||
container: container,
|
||||
video: videoEl,
|
||||
apiResponse: apiResponse,
|
||||
streamServer: streamServer,
|
||||
streamServers: streamServers,
|
||||
fallbackServers: fallbackServers,
|
||||
streamServerIndex: streamServerIndex,
|
||||
av1Supported: av1Supported,
|
||||
dashSupported: dashSupported,
|
||||
poster: apiResponse.poster,
|
||||
title: apiResponse.title,
|
||||
data: data,
|
||||
subtitleTracks: subtitleTracks,
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
captionsActive: captions,
|
||||
captionLanguage: 'en',
|
||||
ambientMode: ambientMode,
|
||||
isMobile: isMobile(),
|
||||
quality: parseInt(localStorage.getItem('hstreamQuality')) || 1080,
|
||||
lastTime: lastTime,
|
||||
subtitleInstance: subtitleInstance,
|
||||
onEnded: function () {
|
||||
playNextPlaylistVideo();
|
||||
},
|
||||
i18n: {
|
||||
qualityLabel: {
|
||||
2161: "2160p48",
|
||||
2160: "2160p",
|
||||
1081: "1080p48",
|
||||
1080: "1080p",
|
||||
720: "720p"
|
||||
},
|
||||
qualityBadge: {
|
||||
2161: "UHD@48",
|
||||
1081: "FHD@48",
|
||||
1080: "FHD",
|
||||
},
|
||||
onTimeUpdate: function () {
|
||||
trackWatchTime();
|
||||
},
|
||||
onQualityChange: function (size) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
initDash(data);
|
||||
}
|
||||
},
|
||||
onVolumeChange: function () {
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||
},
|
||||
onCaptionsToggle: function (active) {
|
||||
localStorage.setItem('hstreamCaptions', active.toString());
|
||||
if (subtitleInstance && subtitleInstance.canvas) {
|
||||
subtitleInstance.canvas.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
var libassParent = document.querySelector('.libassjs-canvas-parent');
|
||||
if (libassParent) {
|
||||
libassParent.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
},
|
||||
onLanguageChange: function (lang) {
|
||||
initSubtitles(lang);
|
||||
if (player) {
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
},
|
||||
onServerChange: function (index) {
|
||||
streamServerIndex = index;
|
||||
var allServers = streamServers.concat(fallbackServers);
|
||||
streamServer = allServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
},
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||
});
|
||||
|
||||
// Player Track Data
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
window.player = player;
|
||||
|
||||
player.source = {
|
||||
type: 'video',
|
||||
title: apiResponse.title,
|
||||
poster: apiResponse.poster,
|
||||
previewThumbnails: {
|
||||
enabled: true,
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||
},
|
||||
sources: data,
|
||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||
};
|
||||
if (player.captionsActive) {
|
||||
initSubtitles(player.captionLanguage);
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
|
||||
player.volume = volume;
|
||||
player.muted = muted;
|
||||
//player.captions.languages = ['en'];
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
if (!isMobile()) {
|
||||
player.initThumbnails(vttThumbsUrl);
|
||||
}
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
});
|
||||
|
||||
initDash(data, player);
|
||||
initDash(data);
|
||||
}
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
initMobileWidescreen(container, videoEl);
|
||||
initMobileDoubleTap(container, videoEl, player);
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
player.initHeatmap(episodeId);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
// Server select (Asia)
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log("Stopped video, because user didn't click play.")
|
||||
}
|
||||
|
||||
// Start engagement heatmap tracking
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
videoEl.addEventListener('play', function onFirstPlay() {
|
||||
videoEl.removeEventListener('play', onFirstPlay);
|
||||
startEngagementTracking(episodeId);
|
||||
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
player.on('seeked', () => {
|
||||
paintStaticVideo(ctx, video);
|
||||
if (player.currentTime > 0) {
|
||||
lastTime = player.currentTime;
|
||||
}
|
||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
if (player.paused) {
|
||||
paintStaticVideo(ctx, video);
|
||||
}
|
||||
});
|
||||
|
||||
player.on('captionsenabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
||||
localStorage.setItem('hstreamCaptions', 'true');
|
||||
console.log('Set Captions Status to Local Storage: true');
|
||||
});
|
||||
|
||||
player.on('captionsdisabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
||||
localStorage.setItem('hstreamCaptions', 'false');
|
||||
console.log('Set Captions Status to Local Storage: false');
|
||||
});
|
||||
|
||||
player.on('volumechange', () => {
|
||||
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString())
|
||||
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString())
|
||||
});
|
||||
|
||||
player.on('ended', () => {
|
||||
playNextPlaylistVideo();
|
||||
});
|
||||
|
||||
// Track watch time after 10 seconds of playback
|
||||
player.on('timeupdate', () => {
|
||||
trackWatchTime();
|
||||
});
|
||||
|
||||
player.on('languagechange', (event) => {
|
||||
let lang = event.detail.plyr.captions.language;
|
||||
|
||||
console.log('Subtitle Event ' + lang);
|
||||
initSubtitles(lang);
|
||||
});
|
||||
|
||||
function playerPlayTemp() {
|
||||
clickedPlay = true;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
||||
play.addEventListener('click', playerPlayTemp)
|
||||
);
|
||||
|
||||
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
||||
|
||||
initMobileWidescreen();
|
||||
|
||||
// Start time
|
||||
setTimeout(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const time = parseInt(params.get("t"));
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var time = parseInt(params.get('t'));
|
||||
if (!isNaN(time)) {
|
||||
player.currentTime = time;
|
||||
console.log("Skipping to " + time)
|
||||
console.log('Skipping to ' + time);
|
||||
}
|
||||
if (lastTime > 0) {
|
||||
player.currentTime = lastTime;
|
||||
console.log("Skipping to " + lastTime)
|
||||
console.log('Skipping to ' + lastTime);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
player.on('ready', () => {
|
||||
mobileDoubleClick(player);
|
||||
|
||||
// Load engagement heatmap once video duration is known
|
||||
const video = document.querySelector('video');
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
if (video && video.duration) {
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
} else if (video) {
|
||||
video.addEventListener('loadedmetadata', function onMeta() {
|
||||
video.removeEventListener('loadedmetadata', onMeta);
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server Select
|
||||
// I hate this...
|
||||
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
||||
if (settingElements.length == 3) {
|
||||
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
||||
|
||||
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
||||
if (settingNodes.length == 4) {
|
||||
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
||||
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
||||
let serverSelects = document.getElementsByClassName('change_server');
|
||||
for (let i = 0; i < serverSelects.length; i++) {
|
||||
serverSelects[i].addEventListener('click', function() {
|
||||
streamServerIndex = Number(this.value);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically save last timestamp
|
||||
saveInterval = setInterval(function () {
|
||||
lastTime = player.currentTime;
|
||||
console.log("Last Player Position: " + lastTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
async function initVidstackPlayer() {
|
||||
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
|
||||
player = await VidstackPlayer.create({
|
||||
target: '#player',
|
||||
@@ -464,57 +269,56 @@ async function initVidstackPlayer() {
|
||||
]
|
||||
});
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
window.player = player;
|
||||
|
||||
player.addEventListener('play', () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Track watch time after 10 seconds of playback
|
||||
player.addEventListener('time-update', () => {
|
||||
player.addEventListener('time-update', function () {
|
||||
trackWatchTime();
|
||||
});
|
||||
}
|
||||
|
||||
// Get Data from API
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
window.setPlayerPreference = function(pref) {
|
||||
localStorage.setItem('hstreamPlayerPreference', pref);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
|
||||
if (playerPreference === 'plyr' && !isIOS()) {
|
||||
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
|
||||
} else {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status === 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains || [];
|
||||
fallbackServers = apiResponse.asia_stream_domains || [];
|
||||
|
||||
const cdnCount = streamServers.length;
|
||||
if (cdnCount > 0) {
|
||||
streamServerIndex = Math.floor(Math.random() * cdnCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
} else {
|
||||
const fallbackCount = fallbackServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * fallbackCount);
|
||||
streamServer = fallbackServers[streamServerIndex];
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length + fallbackServers.length;
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
} else {
|
||||
console.log('Detected Apple device. Using Vidstack fallback player.');
|
||||
initVidstackPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
console.log("Detected Apple Shit. Using different player.")
|
||||
initVidstackPlayer();
|
||||
}
|
||||
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById("player-alert");
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
|
||||
if (dashSupported) {
|
||||
return addDashTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
return addLegacyTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
|
||||
function addDashTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
|
||||
size: 720,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (av1Supported) {
|
||||
// 1080p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
|
||||
size: 1080,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
// 2160p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
|
||||
size: 2160,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (apiResponse.interpolated == 1) {
|
||||
// 1080p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
|
||||
size: 1081,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
|
||||
if (apiResponse.interpolated_uhd == 1) {
|
||||
// 2160p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
|
||||
size: 2161,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
|
||||
type: 'video/mp4',
|
||||
size: 720,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
export function addSubtitleTracks(streamServer, apiResponse) {
|
||||
var data = [];
|
||||
|
||||
// Default
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: 'English',
|
||||
srclang: 'en',
|
||||
src: '',
|
||||
default: true,
|
||||
});
|
||||
|
||||
for (var key in apiResponse.extra_subtitles) {
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
|
||||
srclang: key,
|
||||
src: '',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Engagement heatmap tracking
|
||||
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||
// Only tracks segment >= 1 (excludes 0-10s).
|
||||
// Only calls the endpoint when the user is logged in.
|
||||
|
||||
let engagementInterval;
|
||||
let engagementSegments = new Set();
|
||||
let engagementReportInterval;
|
||||
const SEGMENT_DURATION = 10; // seconds per segment
|
||||
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||
|
||||
function isAuthenticated() {
|
||||
const el = document.getElementById('auth_check');
|
||||
return el && el.value === '1';
|
||||
}
|
||||
|
||||
function sendEngagement(episodeId, segments) {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
window.axios.post('/player/engagement', {
|
||||
episode_id: episodeId,
|
||||
segments: segments,
|
||||
}).catch(() => {
|
||||
// Fire-and-forget: silently ignore network errors
|
||||
});
|
||||
}
|
||||
|
||||
export function startEngagementTracking(episodeId) {
|
||||
engagementSegments.clear();
|
||||
|
||||
// Sample current time while playing
|
||||
engagementInterval = setInterval(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (!video || video.paused) return;
|
||||
|
||||
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||
// Skip segment 0 (0-10s) — no need to track the very start
|
||||
if (segment >= 1) {
|
||||
engagementSegments.add(segment);
|
||||
}
|
||||
}, SAMPLE_INTERVAL);
|
||||
|
||||
// Batch report to server
|
||||
engagementReportInterval = setInterval(() => {
|
||||
if (engagementSegments.size === 0) return;
|
||||
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
|
||||
sendEngagement(episodeId, segments);
|
||||
}, REPORT_INTERVAL);
|
||||
|
||||
// Flush remaining segments & cleanup on page unload
|
||||
const cleanup = () => {
|
||||
clearInterval(engagementInterval);
|
||||
clearInterval(engagementReportInterval);
|
||||
|
||||
if (engagementSegments.size > 0) {
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
sendEngagement(episodeId, segments);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
|
||||
export function stopEngagementTracking() {
|
||||
if (engagementInterval) clearInterval(engagementInterval);
|
||||
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Engagement heatmap display
|
||||
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
|
||||
|
||||
let heatmapContainer = null;
|
||||
let heatmapCanvas = null;
|
||||
let heatmapResizeObserver = null;
|
||||
|
||||
/**
|
||||
* Fetch engagement data from the server and render the heatmap.
|
||||
* @param {string} episodeId - The episode ID.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
export async function renderHeatmap(episodeId, duration) {
|
||||
try {
|
||||
const response = await window.axios.get(`/player/engagement/${episodeId}`);
|
||||
const data = response.data;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawHeatmapCurve(data, duration);
|
||||
} catch (error) {
|
||||
console.error('Failed to load engagement data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a smooth area chart on a canvas element above the progress bar.
|
||||
* @param {Object} data - Key-value map of segment -> watch_count.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
function drawHeatmapCurve(data, duration) {
|
||||
const SEGMENT_DURATION = 10;
|
||||
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
|
||||
|
||||
// Build raw counts array, filling gaps with 0
|
||||
const rawCounts = [];
|
||||
for (let i = 0; i < totalSegments; i++) {
|
||||
rawCounts.push(data[i] || 0);
|
||||
}
|
||||
|
||||
// Apply weighted moving average to smooth individual spikes
|
||||
const counts = smoothData(rawCounts);
|
||||
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
|
||||
// Remove existing heatmap if present
|
||||
if (heatmapContainer) {
|
||||
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
|
||||
heatmapContainer.remove();
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
|
||||
const progressBar = document.querySelector('.hstream-player__progress');
|
||||
if (!progressBar) return;
|
||||
|
||||
heatmapContainer = document.createElement('div');
|
||||
heatmapContainer.className = 'hstream-player__progress-heatmap';
|
||||
heatmapContainer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
heatmapCanvas = document.createElement('canvas');
|
||||
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
|
||||
heatmapContainer.appendChild(heatmapCanvas);
|
||||
|
||||
// Insert as first child of the progress bar so it sits behind the scrubber
|
||||
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
|
||||
|
||||
// Defer drawing to get container dimensions
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
|
||||
});
|
||||
|
||||
// Redraw on resize
|
||||
heatmapResizeObserver = new ResizeObserver(() => {
|
||||
drawCurve(heatmapCanvas, counts, maxCount);
|
||||
});
|
||||
heatmapResizeObserver.observe(heatmapContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply two passes of a 5-point weighted (Gaussian) moving average.
|
||||
* Near edges falls back to a 3-point average.
|
||||
* Preserves the first and last points.
|
||||
*/
|
||||
function smoothData(data) {
|
||||
if (data.length <= 2) return [...data];
|
||||
|
||||
let result = data;
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const smoothed = [result[0]];
|
||||
|
||||
for (let i = 1; i < result.length - 1; i++) {
|
||||
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
|
||||
smoothed.push(
|
||||
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
|
||||
result[i + 1] * 2 + result[i + 2] * 1) / 10
|
||||
);
|
||||
} else {
|
||||
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
smoothed.push(result[result.length - 1]);
|
||||
result = smoothed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vertical bar heatmap directly on the progress bar track.
|
||||
* Each bar represents a time segment; taller bars = more engagement.
|
||||
*/
|
||||
function drawCurve(canvas, counts, maxCount) {
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const rect = parent.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (counts.length === 0 || maxCount === 0) return;
|
||||
|
||||
const paddingX = 1;
|
||||
const paddingY = 2;
|
||||
const drawW = w - paddingX * 2;
|
||||
const drawH = h - paddingY * 2;
|
||||
const n = counts.length;
|
||||
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = paddingX + (i / (n - 1 || 1)) * drawW;
|
||||
const ratio = Math.min(counts[i] / maxCount, 1);
|
||||
const y = paddingY + (1 - ratio) * drawH;
|
||||
pts.push({ x, y });
|
||||
}
|
||||
|
||||
if (pts.length < 2) return;
|
||||
|
||||
// Build the smooth path using quadratic bezier curves through midpoints
|
||||
const path = [{ x: pts[0].x, y: pts[0].y }];
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const midX = (pts[i].x + pts[i + 1].x) / 2;
|
||||
const midY = (pts[i].y + pts[i + 1].y) / 2;
|
||||
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
|
||||
}
|
||||
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
|
||||
|
||||
// --- Draw a subtle glow behind the line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 3.0;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// --- Draw the main waveform line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the heatmap from the DOM.
|
||||
*/
|
||||
export function removeHeatmap() {
|
||||
if (heatmapResizeObserver) {
|
||||
heatmapResizeObserver.disconnect();
|
||||
heatmapResizeObserver = null;
|
||||
}
|
||||
if (heatmapContainer) {
|
||||
heatmapContainer.remove();
|
||||
heatmapContainer = null;
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Mobile-specific player features:
|
||||
* - Double-tap left/right to skip ±10s
|
||||
* - Object-fit toggle button for widescreen fill
|
||||
*/
|
||||
|
||||
export function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function initMobileWidescreen(playerWrapper, video) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = playerWrapper.querySelector('.hstream-player__controls');
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Toggle screen fill');
|
||||
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
|
||||
btn.title = 'Fill Screen';
|
||||
|
||||
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
|
||||
} else {
|
||||
controls.appendChild(btn);
|
||||
}
|
||||
|
||||
let fillEnabled = true;
|
||||
video.style.objectFit = 'cover';
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (fillEnabled) {
|
||||
video.style.objectFit = 'contain';
|
||||
fillEnabled = false;
|
||||
btn.classList.remove('hstream-player__button--active');
|
||||
} else {
|
||||
video.style.objectFit = 'cover';
|
||||
fillEnabled = true;
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
});
|
||||
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
|
||||
export function initMobileDoubleTap(playerWrapper, video, player) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
|
||||
if (!skipOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
class MultiClickCounter {
|
||||
constructor() {
|
||||
this.timers = [];
|
||||
this.count = 0;
|
||||
this.reseted = 0;
|
||||
this.lastSide = null;
|
||||
}
|
||||
|
||||
clicked() {
|
||||
this.count += 1;
|
||||
const xcount = this.count;
|
||||
this.timers.push(setTimeout(() => this.reset(xcount), 500));
|
||||
return this.count;
|
||||
}
|
||||
|
||||
resetCount(n) {
|
||||
this.reseted = this.count;
|
||||
this.count = n;
|
||||
this.timers.forEach(t => clearTimeout(t));
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
reset(xcount) {
|
||||
if (this.count > xcount) return;
|
||||
this.count = 0;
|
||||
this.lastSide = null;
|
||||
this.reseted = 0;
|
||||
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
|
||||
this.timers = [];
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new MultiClickCounter();
|
||||
|
||||
const handleTap = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const count = counter.clicked();
|
||||
if (count < 2) return;
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const perc = (x / rect.width) * 100;
|
||||
|
||||
let shouldReset = true;
|
||||
const lastSide = counter.lastSide;
|
||||
|
||||
if (lastSide === null) {
|
||||
shouldReset = false;
|
||||
}
|
||||
|
||||
if (perc < 40) {
|
||||
if (player.currentTime === 0) return;
|
||||
counter.lastSide = 'L';
|
||||
if (shouldReset && lastSide !== 'L') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else if (perc > 60) {
|
||||
if (player.currentTime >= player.duration) return;
|
||||
counter.lastSide = 'R';
|
||||
if (shouldReset && lastSide !== 'R') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else {
|
||||
player.togglePlay();
|
||||
counter.lastSide = 'C';
|
||||
}
|
||||
};
|
||||
|
||||
playerWrapper.addEventListener('click', handleTap);
|
||||
|
||||
video.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Builds the server/CDN selector submenu panel for the settings menu.
|
||||
* @param {string[]} streamServers - Regular CDN server URLs
|
||||
* @param {string[]} fallbackServers - Fallback server URLs
|
||||
* @param {number} selectedIndex - Index in the combined server list
|
||||
* @param {function} onSelect - Callback receiving the combined index
|
||||
*/
|
||||
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hstream-player__menu-panel';
|
||||
panel.setAttribute('data-panel', 'server');
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'hstream-player__menu-back';
|
||||
backBtn.type = 'button';
|
||||
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
|
||||
backBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const menuContainer = panel.closest('.hstream-player__menu-container');
|
||||
if (menuContainer) {
|
||||
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
|
||||
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
|
||||
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
|
||||
}
|
||||
});
|
||||
panel.appendChild(backBtn);
|
||||
|
||||
const addServerItems = (servers, labelPrefix, startIndex) => {
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const index = startIndex + i;
|
||||
const item = document.createElement('button');
|
||||
item.className = 'hstream-player__menu-item';
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitemradio');
|
||||
|
||||
if (index === selectedIndex) {
|
||||
item.classList.add('hstream-player__menu-item--checked');
|
||||
item.setAttribute('aria-checked', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
}
|
||||
|
||||
const num = i + 1;
|
||||
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(index);
|
||||
});
|
||||
panel.appendChild(item);
|
||||
}
|
||||
};
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(divider);
|
||||
|
||||
addServerItems(streamServers, 'Server', 0);
|
||||
|
||||
if (fallbackServers && fallbackServers.length > 0) {
|
||||
const fbDivider = document.createElement('div');
|
||||
fbDivider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(fbDivider);
|
||||
|
||||
addServerItems(fallbackServers, 'Fallback', streamServers.length);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* VTT-based sprite thumbnail preview.
|
||||
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
|
||||
* a floating preview image above the progress bar on hover.
|
||||
*/
|
||||
|
||||
export class ThumbnailPreview {
|
||||
constructor(progressWrapper, video) {
|
||||
this.progressWrapper = progressWrapper;
|
||||
this.video = video;
|
||||
this.cues = [];
|
||||
this.spriteImg = null;
|
||||
this.thumbnailWidth = 160;
|
||||
this.thumbnailHeight = 90;
|
||||
this.visible = false;
|
||||
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'hstream-player__thumbnail-preview';
|
||||
this.el.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.imgEl = document.createElement('div');
|
||||
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
|
||||
this.el.appendChild(this.imgEl);
|
||||
|
||||
this.timeEl = document.createElement('div');
|
||||
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
|
||||
this.el.appendChild(this.timeEl);
|
||||
|
||||
this.el.style.display = 'none';
|
||||
this.progressWrapper.appendChild(this.el);
|
||||
|
||||
this._onMove = this._onMove.bind(this);
|
||||
this._onLeave = this._onLeave.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the thumbnail VTT file.
|
||||
* @param {string} vttUrl
|
||||
*/
|
||||
async load(vttUrl) {
|
||||
try {
|
||||
const response = await fetch(vttUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VTT: ' + response.status);
|
||||
}
|
||||
const text = await response.text();
|
||||
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
|
||||
this.cues = this._parseVTT(text, baseDir);
|
||||
if (this.cues.length > 0) {
|
||||
this.spriteImg = new Image();
|
||||
this.spriteImg.crossOrigin = 'anonymous';
|
||||
this.spriteImg.src = this.cues[0].spriteUrl;
|
||||
await new Promise((resolve, reject) => {
|
||||
this.spriteImg.onload = resolve;
|
||||
this.spriteImg.onerror = reject;
|
||||
});
|
||||
}
|
||||
this._attach();
|
||||
} catch (err) {
|
||||
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WEBVTT text extracting cues with sprite coordinates.
|
||||
*/
|
||||
_parseVTT(text, baseDir) {
|
||||
const cues = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
|
||||
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
|
||||
|
||||
const resolveUrl = (maybeRelative) => {
|
||||
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
|
||||
return maybeRelative;
|
||||
}
|
||||
try {
|
||||
return new URL(maybeRelative, baseDir).href;
|
||||
} catch (e) {
|
||||
return baseDir + maybeRelative;
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
const match = line.match(cueRegex);
|
||||
if (match) {
|
||||
const startTime = this._timeToSeconds(match[1]);
|
||||
const endTime = this._timeToSeconds(match[2]);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const payload = lines[i].trim();
|
||||
if (payload === '' || payload.match(cueRegex)) {
|
||||
break;
|
||||
}
|
||||
const xywh = payload.match(xywhRegex);
|
||||
if (xywh) {
|
||||
const rawUrl = payload.substring(0, xywh.index);
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
spriteUrl: resolveUrl(rawUrl),
|
||||
x: parseInt(xywh[1], 10),
|
||||
y: parseInt(xywh[2], 10),
|
||||
w: parseInt(xywh[3], 10),
|
||||
h: parseInt(xywh[4], 10),
|
||||
});
|
||||
break;
|
||||
}
|
||||
const noteMatch = payload.match(/^NOTE/);
|
||||
if (!noteMatch) {
|
||||
const urlMatch = payload.match(/^(\S+)/);
|
||||
if (urlMatch) {
|
||||
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
_timeToSeconds(timestamp) {
|
||||
const [h, m, s] = timestamp.split(':');
|
||||
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
|
||||
}
|
||||
|
||||
_attach() {
|
||||
this.progressWrapper.addEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
|
||||
this.progressWrapper.addEventListener('touchend', this._onLeave);
|
||||
}
|
||||
|
||||
_onMove(e) {
|
||||
const rect = this.progressWrapper.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const time = ratio * this.video.duration;
|
||||
|
||||
const cue = this._findCue(time);
|
||||
if (!cue) {
|
||||
this._hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this._show(cue, time, rect, x);
|
||||
}
|
||||
|
||||
_findCue(time) {
|
||||
for (let i = 0; i < this.cues.length; i++) {
|
||||
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
|
||||
return this.cues[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_show(cue, time, progressRect, mouseX) {
|
||||
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
|
||||
this.imgEl.style.width = cue.w + 'px';
|
||||
this.imgEl.style.height = cue.h + 'px';
|
||||
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
|
||||
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
|
||||
const containerWidth = this.progressWrapper.offsetWidth;
|
||||
const halfW = cue.w / 2;
|
||||
let left = mouseX;
|
||||
if (left < halfW + 4) left = halfW + 4;
|
||||
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
|
||||
|
||||
this.el.style.left = left + 'px';
|
||||
this.el.style.display = '';
|
||||
|
||||
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
|
||||
if (timeTooltip) {
|
||||
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
|
||||
}
|
||||
|
||||
if (!this.visible) {
|
||||
this.visible = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.el.classList.add('hstream-player__thumbnail-preview--visible');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_hide() {
|
||||
this.visible = false;
|
||||
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
|
||||
setTimeout(() => {
|
||||
if (!this.visible) {
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
_onLeave() {
|
||||
this._hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.progressWrapper.removeEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.removeEventListener('touchmove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('touchend', this._onLeave);
|
||||
if (this.el.parentNode) {
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,107 @@ export function playNextPlaylistVideo() {
|
||||
|
||||
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();
|
||||
});
|
||||
+12
-15
@@ -7,7 +7,7 @@ function darkModeListener() {
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("input[type='checkbox']#toogleTheme").forEach((toggle) => toggle.addEventListener("click", darkModeListener));
|
||||
document.querySelector("input[type='checkbox']#toogleTheme")?.addEventListener("click", darkModeListener);
|
||||
|
||||
if(localStorage.theme) {
|
||||
if (localStorage.theme == 'light') {
|
||||
@@ -15,9 +15,10 @@ if(localStorage.theme) {
|
||||
document.querySelector("html").classList.toggle("dark");
|
||||
}
|
||||
|
||||
document.querySelectorAll("#toogleTheme").forEach((toggle) => {
|
||||
toggle.checked = true;
|
||||
});
|
||||
const toggleThemeButton = document.getElementById("toogleTheme");
|
||||
if (toggleThemeButton) {
|
||||
toggleThemeButton.checked = true;
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
@@ -27,6 +28,7 @@ if(localStorage.theme) {
|
||||
|
||||
// Ability to disable blur effects for slower devices
|
||||
const LOCAL_STORAGE_KEY = 'blur';
|
||||
const blurCheckbox = document.querySelector("input[type='checkbox']#toggleBlur");
|
||||
|
||||
function setCSSFilter(selector, value) {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
@@ -36,12 +38,10 @@ function setCSSFilter(selector, value) {
|
||||
|
||||
function applyBlur(enabled) {
|
||||
if (!enabled) {
|
||||
setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg, .backdrop-blur-xl, .backdrop-blur-2xl', 'none');
|
||||
setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg', 'none');
|
||||
return;
|
||||
}
|
||||
|
||||
setCSSFilter('.backdrop-blur-2xl', 'blur(40px)');
|
||||
setCSSFilter('.backdrop-blur-xl', 'blur(24px)');
|
||||
setCSSFilter('.backdrop-blur-lg', 'blur(16px)');
|
||||
setCSSFilter('.backdrop-blur', 'blur(8px)');
|
||||
setCSSFilter('.backdrop-blur-sm', 'blur(4px)');
|
||||
@@ -51,22 +51,19 @@ function initBlurToggle() {
|
||||
const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
const enabled = storedValue === null ? true : storedValue === 'true';
|
||||
|
||||
const blurCheckboxes = document.querySelectorAll("input[type='checkbox']#toggleBlur");
|
||||
|
||||
// initialize UI and DOM
|
||||
applyBlur(enabled);
|
||||
blurCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = enabled;
|
||||
});
|
||||
if (blurCheckbox) blurCheckbox.checked = enabled;
|
||||
|
||||
// add event listener
|
||||
blurCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener('click', (e) => {
|
||||
if (blurCheckbox) {
|
||||
blurCheckbox.addEventListener('click', (e) => {
|
||||
console.log("Received Event");
|
||||
const isEnabled = e.target.checked;
|
||||
applyBlur(isEnabled);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initBlurToggle();
|
||||
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
|
||||
@include('layouts.navigation')
|
||||
@include('partials.background')
|
||||
<div class="mt-[80px]">
|
||||
<div class="mt-[65px]">
|
||||
@include('admin.partials.sidenav')
|
||||
<div class="pl-64">
|
||||
@yield('content')
|
||||
|
||||
@@ -6,8 +6,76 @@
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4 pt-0">
|
||||
@livewire('admin-episode-form', ['episodeId' => $episode->id])
|
||||
<form method="POST" action="{{ route('admin.upload.episode') }}" enctype="multipart/form-data">
|
||||
@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>
|
||||
<!-- Modals JS -->
|
||||
<script>
|
||||
document.getElementById('episode_id').value = document.getElementById('e_id').value;
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,111 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('content')
|
||||
@livewire('admin-release-form')
|
||||
@endsection
|
||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||
<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
|
||||
|
||||
@vite(['resources/js/admin-release.js'])
|
||||
<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
|
||||
@@ -1 +1 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@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'])
|
||||
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-neutral-800'])
|
||||
|
||||
@php
|
||||
$alignmentClasses = match ($align) {
|
||||
@@ -9,9 +9,6 @@ $alignmentClasses = match ($align) {
|
||||
|
||||
$width = match ($width) {
|
||||
'48' => 'w-48',
|
||||
'56' => 'w-56',
|
||||
'64' => 'w-64',
|
||||
'72' => 'w-72',
|
||||
default => $width,
|
||||
};
|
||||
@endphp
|
||||
@@ -28,10 +25,10 @@ $width = match ($width) {
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-2xl shadow-lg {{ $alignmentClasses }}"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
|
||||
style="display: none;"
|
||||
@click="open = false">
|
||||
<div class="rounded-2xl ring-1 ring-black/5 {{ $contentClasses }}">
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
@props(['active' => false, 'label' => null])
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = $active
|
||||
? 'inline-flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-rose-600 to-pink-600 text-white shadow-md shadow-rose-600/25 max-lg:w-9 lg:px-3.5 [&>i]:shrink-0'
|
||||
: 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-600 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-gray-300 dark:ring-neutral-700 dark:hover:bg-rose-950/30 dark:hover:text-rose-300';
|
||||
$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 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';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }} @if ($label && ! $active) title="{{ $label }}" @endif>
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
|
||||
@if ($active && $label)
|
||||
<span class="hidden truncate text-sm font-semibold lg:inline-flex">{{ $label }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? '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'
|
||||
: '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';
|
||||
? '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'
|
||||
: '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';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<!-- Page Heading -->
|
||||
@if (isset($header))
|
||||
<header class="bg-white dark:bg-neutral-950/50 backdrop-blur-lg shadow mt-[80px] z-10">
|
||||
<header class="bg-white dark:bg-neutral-950/50 backdrop-blur-lg shadow mt-[65px] z-10">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{{ $header }}
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
@endif
|
||||
|
||||
<!-- Page Content -->
|
||||
<div @if (!isset($header)) class="mt-[80px]" @else class="mt-[40px]" @endif>
|
||||
<div @if (!isset($header)) class="mt-[65px]" @else class="mt-[40px]" @endif>
|
||||
<main>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
@@ -1,133 +1,152 @@
|
||||
<nav x-data="{ open: false }" class="fixed inset-x-0 top-0 z-50">
|
||||
@php $notAvailable = auth()->check() ? auth()->user()->unreadNotifications()->count() > 0 : false; @endphp
|
||||
|
||||
<div class="mx-auto max-w-[100%] px-3 pt-3 sm:px-5 xl:max-w-[95%] 2xl:max-w-[84%]">
|
||||
<div class="grid h-16 grid-cols-[1fr_auto_1fr] items-center gap-2 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:gap-3 sm:px-5">
|
||||
{{-- Left column: brand + inline links --}}
|
||||
<div class="flex min-w-0 items-center justify-self-start gap-1">
|
||||
<a href="{{ route('home.index') }}" class="flex shrink-0 items-center gap-2.5">
|
||||
<span class="relative">
|
||||
<img src="/images/cropped-HS-1-192x192.webp" class="h-9 w-9 rounded-xl " alt="hstream.moe Logo" />
|
||||
</span>
|
||||
<span class="text-lg font-bold bg-gradient-to-r from-rose-600 to-pink-500 bg-clip-text text-transparent md:hidden xl:block truncate">hstream.moe</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden min-w-0 items-center gap-1 md:flex">
|
||||
<x-nav-link :href="route('home.index')" :active="request()->routeIs('home.index')" :label="__('nav.home')">
|
||||
<i class="fa-solid fa-house"></i>
|
||||
</x-nav-link>
|
||||
|
||||
<x-nav-link :href="route('hentai.search')" :active="request()->routeIs('hentai.search', 'hentai.searchredirect')" :label="__('nav.browse')">
|
||||
<i class="fa-solid fa-compass"></i>
|
||||
</x-nav-link>
|
||||
|
||||
<x-nav-link :href="route('playlist.index')" :active="request()->routeIs('playlist.*')" :label="__('nav.public-playlists')">
|
||||
<i class="fa-solid fa-rectangle-list"></i>
|
||||
</x-nav-link>
|
||||
|
||||
@auth
|
||||
<x-nav-link :href="route('download.search')" :active="request()->routeIs('download.search')" :label="__('nav.downloads')">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</x-nav-link>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Center column: live search --}}
|
||||
<div class="hidden items-center justify-self-center md:flex">
|
||||
@livewire('nav-live-search')
|
||||
</div>
|
||||
|
||||
{{-- Right column: right cluster + hamburger --}}
|
||||
<div class="flex min-w-0 items-center justify-self-end col-start-3 gap-1.5">
|
||||
<div class="hidden items-center gap-1.5 md:flex">
|
||||
{{-- Community dropdown --}}
|
||||
<x-dropdown align="right" width="56">
|
||||
<nav x-data="{ open: false }"
|
||||
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">
|
||||
<!-- 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 type="button" title="{{ __('nav.community') }}"
|
||||
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
|
||||
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">
|
||||
<img src="/images/cropped-HS-1-192x192.webp" class="h-8 mr-3" alt="hstream.moe Logo" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap">hstream.moe</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-1">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20">
|
||||
<path fill-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"
|
||||
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 text-indigo-500 dark:text-indigo-400"></i> {{ __('nav.our-discord-server') }}
|
||||
<i class="fa-brands fa-discord"></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
|
||||
<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 :href="route('contact.index')">
|
||||
<i class="fa-solid fa-message"></i> Contact
|
||||
<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>
|
||||
|
||||
{{-- 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>
|
||||
<div class="items-center hidden md:flex">
|
||||
@livewire('nav-live-search')
|
||||
<div class="hidden lg:block pl-4">
|
||||
<div class="flex flex-col items-center bg-gray-50/20 dark:bg-neutral-900/40 rounded-md">
|
||||
<a href="{{ route('hentai.random') }}"
|
||||
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">
|
||||
<i class="fa-solid fa-shuffle"></i>
|
||||
<p class="md:pl-1 pl-0">Random</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Theme toggle --}}
|
||||
@include('partials.themeswitcher')
|
||||
@auth
|
||||
@php $notAvailable = Auth::user()->unreadNotifications()->count() > 0; @endphp
|
||||
@else
|
||||
@php $notAvailable = false; @endphp
|
||||
@endauth
|
||||
|
||||
{{-- Account dropdown --}}
|
||||
<x-dropdown align="right" width="72">
|
||||
<!-- Settings Dropdown -->
|
||||
<div class="hidden sm:flex sm:items-center sm:ml-6">
|
||||
<x-dropdown align="right" width="48">
|
||||
<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">
|
||||
<button
|
||||
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">
|
||||
@auth
|
||||
<img class="h-9 w-9 rounded-full object-cover ring-2 ring-rose-500/50"
|
||||
<img class="h-8 w-8 rounded-full object-cover mr-2"
|
||||
src="{{ Auth::user()->getAvatar() }}"
|
||||
alt="{{ Auth::user()->name }}" />
|
||||
<span class="hidden items-center gap-1.5 text-sm font-semibold text-gray-800 dark:text-neutral-200 xl:inline-flex">
|
||||
{{ Auth::user()->name }}
|
||||
@if ($notAvailable)
|
||||
<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
|
||||
</span>
|
||||
@else
|
||||
<img class="h-9 w-9 rounded-full object-cover ring-2 ring-rose-500/50" src="/images/default-avatar.webp"
|
||||
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
|
||||
alt="Guest" />
|
||||
<span class="hidden flex-col items-start leading-tight xl:flex">
|
||||
<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
|
||||
|
||||
<svg class="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-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"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
@auth
|
||||
<div style="display: flex; flex-direction: row; align-items: flex-start;">
|
||||
{{ Auth::user()->name }}
|
||||
@if ($notAvailable)
|
||||
<i class="fa-solid fa-bell text-rose-600"></i>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-start;">
|
||||
Guest
|
||||
<small>{{ __('nav.please-login') }}</small>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<div class="ml-1">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20">
|
||||
<path fill-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"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
@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')">
|
||||
<i class="fa-solid fa-user"></i> {{ __('nav.profile') }}
|
||||
<i class="fa-solid fa-user"></i> {{ __('Profile') }}
|
||||
</x-dropdown-link>
|
||||
|
||||
@if ($notAvailable)
|
||||
@@ -156,10 +175,6 @@
|
||||
<i class="fa-solid fa-eye"></i> {{ __('nav.watched') }}
|
||||
</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')">
|
||||
<i class="fa-solid fa-gear"></i> {{ __('nav.settings') }}
|
||||
</x-dropdown-link>
|
||||
@@ -169,11 +184,10 @@
|
||||
<i class="fa-solid fa-user-tie"></i> Admin
|
||||
</x-dropdown-link>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
@include('partials.blurswitcher')
|
||||
|
||||
<div class="my-1 border-t border-gray-100 dark:border-white/5"></div>
|
||||
|
||||
<!-- Authentication -->
|
||||
@auth
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
@@ -188,202 +202,122 @@
|
||||
@guest
|
||||
<x-dropdown-link :href="route('login')">
|
||||
<div
|
||||
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">
|
||||
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
|
||||
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
|
||||
</div>
|
||||
</x-dropdown-link>
|
||||
@endguest
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Hamburger --}}
|
||||
<div class="flex items-center md:hidden">
|
||||
<button @click="open = ! open" type="button"
|
||||
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-5 w-5" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex"
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{ 'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@if ($notAvailable)
|
||||
<span class="absolute -right-0.5 -top-0.5 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>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
<!-- Hamburger -->
|
||||
<div class="-mr-2 flex items-center sm:hidden">
|
||||
<button @click="open = ! open"
|
||||
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">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex"
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{ 'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@if ($notAvailable)
|
||||
<span class="absolute mb-4 ml-4 flex h-3 w-3 float-right">
|
||||
<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>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Drawer overlay --}}
|
||||
<div x-show="open" @click="open = false"
|
||||
class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm md:hidden"
|
||||
style="display: none"></div>
|
||||
<!-- Responsive Navigation Menu -->
|
||||
@auth
|
||||
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
@include('partials.mobilesearch')
|
||||
</div>
|
||||
|
||||
{{-- Slide-in drawer --}}
|
||||
<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">
|
||||
<!-- Responsive Settings Options -->
|
||||
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600 dark:bg-neutral-900/30">
|
||||
|
||||
{{-- Drawer header --}}
|
||||
<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"
|
||||
<div class="flex justify-center">
|
||||
<img class="h-8 w-8 rounded-full object-cover mr-2"
|
||||
src="{{ Auth::user()->getAvatar() }}"
|
||||
alt="{{ Auth::user()->name }}" />
|
||||
<span class="truncate text-sm font-semibold text-gray-800 dark:text-neutral-200">{{ Auth::user()->name }}</span>
|
||||
@else
|
||||
<img class="h-10 w-10 rounded-full object-cover ring-2 ring-rose-500/50" src="/images/default-avatar.webp"
|
||||
alt="Guest" />
|
||||
<span class="truncate text-sm font-semibold text-gray-800 dark:text-neutral-200">Guest</span>
|
||||
@endauth
|
||||
</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>
|
||||
<span class="font-medium text-base text-gray-800 dark:text-neutral-200">
|
||||
{{ Auth::user()->name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Primary links --}}
|
||||
<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 class="space-y-1">
|
||||
<x-responsive-nav-link :href="route('home.index')" :active="request()->routeIs('home.index')">
|
||||
<i class="fa-solid fa-house"></i> {{ __('nav.home') }}
|
||||
</x-responsive-nav-link>
|
||||
|
||||
<x-responsive-nav-link :href="route('hentai.search')" :active="request()->routeIs('hentai.search', 'hentai.searchredirect')">
|
||||
<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>
|
||||
|
||||
{{-- 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>
|
||||
<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>
|
||||
@else
|
||||
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
|
||||
<i class="fa-solid fa-bell"></i> Notifications
|
||||
|
||||
@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>
|
||||
@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 :href="route('profile.likes')" :active="request()->routeIs('profile.likes')">
|
||||
<i class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}
|
||||
</x-responsive-nav-link>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
||||
{{-- 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>
|
||||
@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')
|
||||
|
||||
{{-- 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 class="pb-1 text-center w-full">
|
||||
<x-responsive-nav-link :href="route('login')">
|
||||
<div
|
||||
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
|
||||
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
|
||||
</div>
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('partials.blurswitcher')
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</nav>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<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>
|
||||
@@ -1,234 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="py-10">
|
||||
<div class="mx-auto px-3 sm:px-5 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[84%]">
|
||||
<div class="mx-auto sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||
@include('livewire.partials.search-filter')
|
||||
</div>
|
||||
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
|
||||
<div class="relative pt-5 mx-auto px-3 sm:px-5 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[84%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
<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%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex justify-center">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@csrf
|
||||
<div class="relative group">
|
||||
<label for="live-search" class="sr-only">Search</label>
|
||||
<div class="relative w-full md:min-w-[220px] lg:min-w-[280px] xl:min-w-[360px]">
|
||||
<div class="relative w-full sm:min-w-[200px] md:min-w-[300px] lg:min-w-[400px] xl:min-w-[500px]">
|
||||
{{-- Search Icon --}}
|
||||
<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">
|
||||
@@ -17,7 +17,7 @@
|
||||
type="search"
|
||||
id="live-search"
|
||||
name="live-search"
|
||||
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"
|
||||
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"
|
||||
placeholder="@if(request()->path() !== 'search'){{ __('search.search-hentai') }}@endif"
|
||||
required
|
||||
@if(request()->path() == 'search') disabled @endif
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
@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>
|
||||
@@ -1,58 +0,0 @@
|
||||
@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>
|
||||
@@ -1,41 +0,0 @@
|
||||
@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,58 +1,57 @@
|
||||
<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%]">
|
||||
|
||||
<!-- Header -->
|
||||
<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="h-24 w-24 shrink-0 rounded-full shadow-lg ring-2 ring-rose-500/20"
|
||||
src="{{ $playlist->user->getAvatar() }}">
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col text-center sm:text-left">
|
||||
<div class="flex text-sm font-light bg-neutral-950/50 backdrop-blur-lg rounded-lg p-10 gap-2">
|
||||
<div>
|
||||
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg"
|
||||
src="{{ $playlist->user->getAvatar() }}">
|
||||
</div>
|
||||
<div class="flex flex-col justify-center flex-1 pl-4">
|
||||
@if ($editingName)
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mb-1 sm:justify-start">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
wire:model="editingPlaylistName"
|
||||
maxlength="30"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@error('editingPlaylistName')
|
||||
<p class="text-rose-500 text-sm mb-1">{{ $message }}</p>
|
||||
<p class="text-rose-400 text-sm mb-1">{{ $message }}</p>
|
||||
@enderror
|
||||
@else
|
||||
<h1 class="flex items-center justify-center gap-1 text-3xl font-bold text-neutral-900 sm:justify-start dark:text-white">
|
||||
<span class="truncate">{{ $playlist->name }}</span>
|
||||
<h1 class="font-bold text-3xl">
|
||||
{{ $playlist->name }}
|
||||
@auth
|
||||
@if (Auth::id() === $playlist->user->id)
|
||||
<button wire:click="editName" class="ml-1 text-xl text-neutral-400 transition hover:text-rose-500" title="Edit playlist name">
|
||||
<button wire:click="editName" class="ml-2 text-xl text-neutral-400 transition hover:text-white" title="Edit playlist name">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
@endif
|
||||
@endauth
|
||||
</h1>
|
||||
@endif
|
||||
<p class="mt-1 text-lg font-light text-neutral-500 dark:text-neutral-300">{{ __('playlist.episodes') }}: {{ $playlist->episodes_count }}</p>
|
||||
<p class="mt-0.5 text-lg font-light text-neutral-500 dark:text-neutral-300">
|
||||
<p class="font-light text-lg text-neutral-200">Episodes: {{ count($playlistEpisodes) }}</p>
|
||||
<p class="font-light text-lg text-neutral-200">
|
||||
Creator: {{ $playlist->user->name }}
|
||||
@auth
|
||||
@if (Auth::id() === $playlist->user->id)
|
||||
<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-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' }}">
|
||||
<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' }}">
|
||||
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
|
||||
{{ $playlist->is_private ? 'Private' : 'Public' }}
|
||||
</button>
|
||||
</span>
|
||||
@else
|
||||
<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' }}">
|
||||
<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' }}">
|
||||
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
|
||||
{{ $playlist->is_private ? 'Private' : 'Public' }}
|
||||
</span>
|
||||
@@ -60,86 +59,88 @@
|
||||
@endauth
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
@if ($firstEpisode)
|
||||
<a href="{{ route('hentai.index', ['title' => $firstEpisode->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">
|
||||
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
|
||||
</a>
|
||||
@else
|
||||
<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">
|
||||
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
|
||||
</a>
|
||||
@endif
|
||||
<div class="flex flex-col justify-center pl-4">
|
||||
<div class="flex justify-end">
|
||||
@php $episode = $playlistEpisodes->first()?->episode; @endphp
|
||||
@if(isset($episode))
|
||||
<a href="{{ route('hentai.index', ['title' => $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>
|
||||
@else
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<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>
|
||||
@forelse($playlistEpisodes as $playlistEpisode)
|
||||
@php $episode = $playlistEpisode->episode; @endphp
|
||||
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}"
|
||||
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="flex pl-5 pr-5 w-10">
|
||||
{{ $playlistEpisode->position }}
|
||||
</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 }}">
|
||||
|
||||
<input
|
||||
wire:model.live.debounce.500ms="search"
|
||||
type="search"
|
||||
placeholder="{{ __('playlist.search-episodes') }}"
|
||||
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"
|
||||
/>
|
||||
@guest
|
||||
<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 collapse md:visible">
|
||||
<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>
|
||||
@endguest
|
||||
|
||||
<div wire:loading.class="opacity-100" class="opacity-0">
|
||||
<div class="absolute inset-y-0 right-3 flex items-center">
|
||||
<svg class="h-5 w-5 animate-spin text-rose-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-90" fill="currentColor"
|
||||
d="M22 12a10 10 0 0 1-10 10V18a6 6 0 0 0 6-6h4Z">
|
||||
</path>
|
||||
</svg>
|
||||
@auth
|
||||
@if ($episode->userWatched(auth()->user()->id))
|
||||
<p
|
||||
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">
|
||||
<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>
|
||||
@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 class="flex shrink-0 items-center gap-2">
|
||||
<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>
|
||||
|
||||
<!-- 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 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>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if ($episodes->hasPages())
|
||||
<div class="mt-6">{{ $episodes->links('pagination::tailwind') }}</div>
|
||||
@endif
|
||||
<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>
|
||||
@empty
|
||||
<div class="pt-6 text-2xl text-center">
|
||||
No results (╥﹏╥)
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
<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>
|
||||
@@ -1,42 +1,27 @@
|
||||
<div>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
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 class="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-rose-600/20 blur-3xl"></div>
|
||||
<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>
|
||||
|
||||
<div class="relative z-10">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
||||
{{ __('nav.public-playlists') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
{{ __('playlist.hero-subtitle') }}
|
||||
</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>
|
||||
<!-- Search -->
|
||||
<div class="p-4 mx-3 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="live-search"
|
||||
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="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<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>
|
||||
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search" placeholder="Search Playlist..."
|
||||
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">
|
||||
<div class="absolute inset-y-0 right-4 flex items-center" wire:loading>
|
||||
<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">
|
||||
|
||||
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search"
|
||||
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"
|
||||
placeholder="Search Playlist...">
|
||||
|
||||
<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
|
||||
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" />
|
||||
@@ -46,165 +31,53 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
||||
<i class="fa-solid fa-sort text-neutral-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>
|
||||
</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>
|
||||
<!-- Ordering -->
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-sort text-gray-500 dark:text-gray-400"></i>
|
||||
</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
|
||||
<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>
|
||||
|
||||
@if ($playlists->hasPages())
|
||||
<div class="mt-10 mb-10">
|
||||
{{ $playlists->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="grid-cols-1 sm:grid md:grid-cols-3" wire:keydown.right.window="nextPage"
|
||||
wire:keydown.left.window="previousPage">
|
||||
|
||||
@foreach ($playlists as $playlist)
|
||||
<div wire:key="playlist-{{ $playlist->id }}"
|
||||
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>
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
<div class="flex w-full items-center justify-between gap-3 px-3 py-2">
|
||||
<p class="cursor-default text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('Blur effects') }}</p>
|
||||
<label for="toggleBlur" class="relative flex cursor-pointer items-center">
|
||||
<!-- input -->
|
||||
<input id="toggleBlur" type="checkbox" class="sr-only" checked />
|
||||
<!-- line -->
|
||||
<div class="w-9 h-5 bg-rose-600 dark:bg-neutral-700 rounded-full shadow-inner">
|
||||
<div class="grid grid-cols-2">
|
||||
<p class="cursor-default">{{ __('Blur effects') }}</p>
|
||||
<div class="flex items-center">
|
||||
<div class="absolute right-6">
|
||||
<label for="toggleBlur" class="flex items-center cursor-pointer">
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-1 flex-grow w-full">
|
||||
<div class="text-gray-700 dark:text-gray-200">{!! $comment->presenter()->markdownBody($limit ?? 250) !!}</div>
|
||||
<div class="text-gray-700 dark:text-gray-200">{!! $comment->presenter()->markdownBody() !!}</div>
|
||||
</div>
|
||||
<div class="mt-2 space-x-2">
|
||||
<span class="text-gray-500 dark:text-gray-300 font-medium">
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<label for="toogleTheme"
|
||||
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">
|
||||
<!-- input -->
|
||||
<input id="toogleTheme" type="checkbox" class="sr-only" />
|
||||
<!-- icon -->
|
||||
<div class="theme-icon flex items-center justify-center transition-transform duration-200">
|
||||
<i class="fa-regular fa-moon text-gray-600 dark:text-gray-200 hidden dark:inline"></i>
|
||||
<i class="fa-regular fa-sun text-yellow-400 dark:hidden"></i>
|
||||
<label for="toogleTheme" class="flex items-center cursor-pointer">
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- input -->
|
||||
<input id="toogleTheme" type="checkbox" class="sr-only" />
|
||||
<!-- 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-[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>
|
||||
</label>
|
||||
@@ -1,5 +1,11 @@
|
||||
<x-app-layout>
|
||||
<div class="mx-auto pt-6 sm:px-6 lg:px-8 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%]">
|
||||
<x-slot name="header">
|
||||
<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')
|
||||
@livewire('playlists')
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
|
||||
@if($isMobile)
|
||||
<div class="flex flex-col">
|
||||
@isset($playlist)
|
||||
<livewire:playlist-sidebar :playlist-id="$playlist->id" :current-episode-id="$episode->id" :collapsible="true" />
|
||||
@endisset
|
||||
@include('stream.partials.playlist')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -29,9 +27,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@if(! $isMobile)
|
||||
@isset($playlist)
|
||||
<livewire:playlist-sidebar :playlist-id="$playlist->id" :current-episode-id="$episode->id" />
|
||||
@endisset
|
||||
@include('stream.partials.playlist')
|
||||
@endif
|
||||
|
||||
@include('stream.partials.more-episodes')
|
||||
|
||||
@@ -8,6 +8,42 @@
|
||||
@endif
|
||||
<canvas id="ambientVideo" class="decoy"></canvas>
|
||||
<div class="relative w-full aspect-[16/9]">
|
||||
<video id="player" playsinline controls crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
<div class="player-switcher" id="player-switcher">
|
||||
<span class="player-switcher__label">Player</span>
|
||||
<button type="button" class="player-switcher__btn" id="plyr-toggle-btn"
|
||||
onclick="window.setPlayerPreference('plyr')" title="Switch to Plyr player">
|
||||
<span class="player-switcher__dot"></span> Plyr
|
||||
</button>
|
||||
<button type="button" class="player-switcher__btn player-switcher__btn--active" id="hstream-toggle-btn"
|
||||
onclick="window.setPlayerPreference('hstream')" title="Switch to HStream player">
|
||||
<span class="player-switcher__dot"></span> HStream
|
||||
</button>
|
||||
</div>
|
||||
<video id="player" playsinline crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var pref = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
var hstreamBtn = document.getElementById('hstream-toggle-btn');
|
||||
var plyrBtn = document.getElementById('plyr-toggle-btn');
|
||||
if (pref === 'plyr') {
|
||||
hstreamBtn.classList.remove('player-switcher__btn--active');
|
||||
plyrBtn.classList.add('player-switcher__btn--active');
|
||||
}
|
||||
|
||||
var switcher = document.getElementById('player-switcher');
|
||||
var container = switcher.parentElement;
|
||||
var hideTimer = null;
|
||||
|
||||
function showSwitcher() {
|
||||
switcher.classList.add('player-switcher--visible');
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(function () {
|
||||
switcher.classList.remove('player-switcher--visible');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
container.addEventListener('pointerdown', showSwitcher);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
@@ -41,6 +41,11 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
|
||||
|
||||
// Release
|
||||
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
|
||||
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ use App\Models\Gallery;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Studios;
|
||||
use App\Services\GalleryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GalleryServiceTest extends TestCase
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
<?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"');
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?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(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user