Compare commits

9 Commits

Author SHA1 Message Date
w33b 60f6c639e5 chore(deps): add Laravel Boost development tooling and configuration
Add laravel/boost as a dev dependency, enable MCP server and skills via boost.json, and include AGENTS.md guidelines. Update .gitignore to exclude .env.testing, /.kilo, and /.cursor.
2026-08-09 13:12:41 +02:00
w33b df6246e3c9 feat(playlist): enhance public playlist index with hero stats and loading optimizations
Add a redesigned hero section to the public playlist page displaying
community-generated totals for playlists and episodes, plus new string
translations (English, German, French). Eager-load user, limited episode
previews, and first gallery image to reduce N+1 queries. Cache aggregate
statistics for 10 minutes to improve performance on high-traffic pages.
2026-08-09 10:36:03 +02:00
w33b 338c190ae8 feat(nav): redesign navigation bar and add Browse/Community links
Enhance the navigation with a modern floating glass design, new Browse and Community dropdown links, and improved theme/blur toggles that now handle multiple instances. Update dropdown components with rounded styling and support for wider widths, refine nav-link active states, and add navigation feature tests.
2026-08-09 10:13:54 +02:00
w33b d08e280ce0 refactor(episode): migrate episode upload to Livewire with real-time CDN verification
Replace the server-rendered episode upload form with a Livewire component,
introducing an interactive admin experience that validates the new episode's
stream path against all configured mirrors before saving. The controller-based
`store` method and its route are removed, and episode creation now flows
through the `AdminEpisodeForm` component with frontend upload handling and
dynamic quality checks.

Key changes:
- Add `AdminEpisodeForm` Livewire component with file uploads, tag handling,
  and debounced stream/base-url validation.
- Extend `CdnPathValidator::validateStream` to accept an episode number so the
  manifest path can point to the specific episode being added.
- Remove the `store` method from `EpisodeController` and its POST route.
- Delete the legacy `createEpisode` method from `EpisodeService`.
- Replace the Blade upload modal with the Livewire view, including progress
  indicators and validation badges.
- Add feature tests for the Livewire component and update unit tests for the
  CDN validator's episode-specific manifest check.
2026-08-08 14:29:05 +02:00
w33b 30a6b080eb feat(admin): rebuild release upload form with Livewire and CDN validation
Replace the server-rendered release upload form with a Livewire component,
introducing an interactive admin release form that supports dynamic episode
management, real‑time CDN path validation, and direct download file checks.

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