16 Commits

Author SHA1 Message Date
w33b a18e2bb095 fix(navigation): show logo text on mobile and truncate overflow
The logo text was previously hidden on all screens except extra-large.
Updated visibility classes to display it on small screens (mobile) while
maintaining the hidden state on medium-to-large screens until the
extra-large breakpoint. Added truncation to prevent horizontal overflow.
2026-08-16 10:22:34 +02:00
w33b 40fe75d9b7 feat(comments): add character limit to comment markdown body
Add an optional `$limit` parameter to `CommentPresenter::markdownBody()`
that truncates the comment body before rendering Markdown. The comment
partial now passes a default limit of 250 characters, preventing long
comments from dominating the layout. Unit tests cover rendering with and
without a limit.
2026-08-16 10:15:08 +02:00
w33b 4199a19dc3 fix(live-search): adjust padding and max-width for layout 2026-08-15 20:19:22 +02:00
w33b 1f582d71d6 feat(nav): make nav links icon-only on mobile with tooltips
- Add label prop to nav-link component to display text labels only when active on large screens
- Restructure navigation layout to use grid with left column for brand and inline links
- Show icon-only links with title tooltips on mobile, full labels on larger screens
- Adjust live search input widths for better responsive scaling across breakpoints
2026-08-10 16:30:49 +02:00
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
w33b 7553b9f895 Update stats page design 2026-07-25 14:18:50 +02:00
w33b 5af1c3c447 Update profile pages design 2026-07-25 13:54:21 +02:00
w33b 2b1a967065 Update nav live search design 2026-07-25 12:38:00 +02:00
112 changed files with 7320 additions and 2076 deletions
+3
View File
@@ -11,6 +11,7 @@
.env
.env.backup
.env.production
.env.testing
.phpunit.result.cache
Homestead.json
Homestead.yaml
@@ -21,3 +22,5 @@ yarn-error.log
/.idea
/.vscode
.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>
+96
View File
@@ -3,11 +3,13 @@
namespace App\Helpers;
use App\Models\Comment;
use App\Models\Downloads;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\PopularDaily;
use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use App\Models\User;
use Conner\Tagging\Model\Tag;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -61,6 +63,100 @@ class CacheHelper
});
}
public static function getTotalUserCount()
{
return Cache::remember('total_user_count', now()->addMinutes(60), function () {
return User::count();
});
}
public static function getTotalCommentCount()
{
return Cache::remember('total_comment_count', now()->addMinutes(60), function () {
return Comment::count();
});
}
public static function getTotalLikeCount()
{
return Cache::remember('total_like_count', now()->addMinutes(60), function () {
return DB::table('markable_likes')->count();
});
}
public static function getTotalDownloadCount()
{
return Cache::remember('total_download_count', now()->addMinutes(60), function () {
return Downloads::sum('count');
});
}
public static function get4kEpisodeCount()
{
return Cache::remember('episodes_4k_count', now()->addMinutes(60), function () {
return Episode::where('interpolated', true)->count();
});
}
public static function getUHD48FpsEpisodeCount()
{
return Cache::remember('episodes_uhd48_count', now()->addMinutes(60), function () {
return Episode::where('interpolated_uhd', true)->count();
});
}
public static function getTodayViewCount()
{
return Cache::remember('today_view_count', now()->addMinutes(30), function () {
return PopularDaily::whereDate('created_at', today())->count();
});
}
public static function getWeeklyViewCount()
{
return Cache::remember('weekly_view_count', now()->addMinutes(60), function () {
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(7))->count();
});
}
public static function getPreviousWeeklyViewCount()
{
return Cache::remember('prev_weekly_view_count', now()->addMinutes(60), function () {
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(14))
->whereDate('created_at', '<', today()->subDays(7))
->count();
});
}
public static function getAverageViewsPerEpisode()
{
return Cache::remember('avg_views_per_episode', now()->addMinutes(60), function () {
$totalEpisodes = Episode::count();
if ($totalEpisodes === 0) {
return 0;
}
return round(Episode::sum('view_count') / $totalEpisodes);
});
}
public static function getNewEpisodesThisWeek()
{
return Cache::remember('new_episodes_this_week', now()->addMinutes(60), function () {
return Episode::whereDate('created_at', '>=', today()->subDays(7))->count();
});
}
public static function getTopTags()
{
return Cache::remember('top_tags_stats', now()->addMinutes(120), function () {
return Tag::where('count', '>', 0)
->orderBy('count', 'desc')
->limit(10)
->get();
});
}
public static function getPopularAllTime(bool $guest)
{
$guestString = $guest ? 'guest' : 'authed';
@@ -30,34 +30,6 @@ class EpisodeController extends Controller
$this->downloadService = $downloadService;
}
/**
* Add Episode to existing series
*/
public function store(Request $request): RedirectResponse
{
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
// Create Episode
$episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode);
$this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1);
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($referenceEpisode->title.' - '.$episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episode->slug, 'release');
}
cache()->flush();
return to_route('hentai.index', [
'title' => $episode->slug,
]);
}
/**
* Edit Episode
*/
@@ -3,33 +3,10 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Hentai;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ReleaseController extends Controller
{
protected EpisodeService $episodeService;
protected GalleryService $galleryService;
protected DownloadService $downloadService;
public function __construct(
EpisodeService $episodeService,
GalleryService $galleryService,
DownloadService $downloadService
) {
$this->episodeService = $episodeService;
$this->galleryService = $galleryService;
$this->downloadService = $downloadService;
}
/**
* Display release page
*/
@@ -37,55 +14,4 @@ class ReleaseController extends Controller
{
return view('admin.release.create');
}
/**
* Upload New Hentai with One or Multipe Episodes
*/
public function store(Request $request): RedirectResponse
{
// Create new Hentai or find existing one
$slug = $this->episodeService->generateSlug($request->input('title'));
$hentai = Hentai::where('slug', $slug)->first();
// If hentai exists and was created today, return to home
if ($hentai?->created_at->isToday()) {
return to_route('home.index');
}
// If hentai does not exist, create a new instance
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $request->input('description1')]
);
// Studio
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
// Create Episode(s)
$releasedEpisodes = [];
for ($i = 1; $i <= $request->input('episodes'); $i++) {
$episode = $this->episodeService->createEpisode($request, $hentai, $i, $studio);
$this->episodeService->createOrUpdateCover($request, $episode, $slug, $i);
$this->downloadService->createOrUpdateDownloads($request, $episode, $i);
$this->galleryService->createOrUpdateGallery($request, $hentai, $episode, $i);
$releasedEpisodes[] = $episode->slug;
}
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored');
} else {
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
}
}
cache()->flush();
return to_route('home.index');
}
}
+12
View File
@@ -103,6 +103,18 @@ class HomeController extends Controller
'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
'userCount' => CacheHelper::getTotalUserCount(),
'commentCount' => CacheHelper::getTotalCommentCount(),
'likeCount' => CacheHelper::getTotalLikeCount(),
'downloadCount' => CacheHelper::getTotalDownloadCount(),
'episodes4k' => CacheHelper::get4kEpisodeCount(),
'episodesUHD48' => CacheHelper::getUHD48FpsEpisodeCount(),
'todayViews' => CacheHelper::getTodayViewCount(),
'weeklyViews' => CacheHelper::getWeeklyViewCount(),
'prevWeeklyViews' => CacheHelper::getPreviousWeeklyViewCount(),
'avgViewsPerEpisode' => CacheHelper::getAverageViewsPerEpisode(),
'newEpisodesThisWeek' => CacheHelper::getNewEpisodesThisWeek(),
'topTags' => CacheHelper::getTopTags(),
]);
}
@@ -20,10 +20,17 @@ class NotificationController extends Controller
}
/**
* Delete Notifcation
* Delete a notification or clear all.
*/
public function delete(Request $request): RedirectResponse
{
// Clear all notifications if no specific ID is provided
if (! $request->has('id')) {
$request->user()->notifications()->delete();
return redirect()->back()->with('status', 'notifications-cleared');
}
$request->validate([
'id' => 'required|exists:notifications,id',
]);
+1 -5
View File
@@ -56,14 +56,11 @@ class StreamController extends Controller
// Playlist
if ($request->has('playlist')) {
// 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
$inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail();
// Get Playlist Episodes and order them
$playlistEpisodes = $playlist->episodes()->orderBy('position')->get();
// Check if authorized
if ($playlist->is_private && (Auth::guest() || (! Auth::guest() && Auth::user()->id != $playlist->user_id))) {
abort(404);
@@ -75,7 +72,6 @@ class StreamController extends Controller
'studioEpisodes' => $studioEpisodes,
'gallery' => $gallery,
'playlist' => $playlist,
'playlistEpisodes' => $playlistEpisodes,
'popularWeekly' => CacheHelper::getPopularWeekly(),
'isMobile' => $isMobile,
]);
+5
View File
@@ -31,6 +31,11 @@ class DiscordReleaseNotification implements ShouldQueue
*/
public function handle(): void
{
// Discord notifications must only ever be sent in production.
if (! app()->isProduction()) {
return;
}
switch ($this->messageType) {
case 'release':
DiscordAlert::message('<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/'.$this->slug);
+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');
}
}
+1
View File
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
if ($this->navSearch != '') {
$episodes = Episode::search($this->navSearch)
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->query(fn ($query) => $query->with(['gallery', 'studio']))
->take(7)
->get();
}
+87 -41
View File
@@ -2,10 +2,11 @@
namespace App\Livewire;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Services\PlaylistService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -18,14 +19,13 @@ class PlaylistOverview extends Component
protected PlaylistService $playlistService;
#[Url(history: true)]
public $search;
public string $search = '';
public int $pagination = 25;
#[Url(history: true)]
public int $perPage = 25;
public Playlist $playlist;
public Collection $playlistEpisodes;
public bool $editingName = false;
public string $editingPlaylistName = '';
@@ -37,35 +37,56 @@ class PlaylistOverview extends Component
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->playlist->episodes->each(function ($item, $index) {
if ($item->position === null) {
$item->position = $index + 1;
$item->save();
}
});
$this->sanitizePerPage();
$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)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
$episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '<', $episode->position)
->orderBy('position', 'desc')
@@ -74,21 +95,20 @@ class PlaylistOverview extends Component
if ($above) {
$this->playlistService->swapPositions($episode, $above);
}
$this->refreshEpisodes();
}
public function moveDown($episodeId)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
$episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '>', $episode->position)
->orderBy('position')
@@ -97,28 +117,28 @@ class PlaylistOverview extends Component
if ($below) {
$this->playlistService->swapPositions($episode, $below);
}
$this->refreshEpisodes();
}
public function remove($episodeId)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
PlaylistEpisode::find($episodeId)?->delete();
$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()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
@@ -134,7 +154,7 @@ class PlaylistOverview extends Component
public function updateName()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
@@ -152,7 +172,7 @@ class PlaylistOverview extends Component
public function toggleVisibility()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
if (! $this->isOwner()) {
return;
}
@@ -160,13 +180,39 @@ class PlaylistOverview extends Component
'is_private' => ! $this->playlist->is_private,
]);
$this->playlist->refresh();
$this->playlist->loadCount('episodes');
}
public function render()
{
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;
use App\Models\Playlist;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
@@ -54,11 +55,30 @@ class Playlists extends Component
->withCount('episodes')
->having('episodes_count', '>', 1)
->when($this->search != '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->with([
'user',
'episodes' => fn ($query) => $query->orderBy('position')->limit(4),
'episodes.episode.gallery' => fn ($query) => $query->limit(1),
])
->orderBy($orderby, $orderdirection)
->paginate($this->pagination);
$stats = Cache::remember('publicPlaylistStats', 600, function () {
$playlists = Playlist::where('is_private', 0)
->withCount('episodes')
->having('episodes_count', '>', 1)
->get();
return [
'playlists' => $playlists->count(),
'episodes' => $playlists->sum('episodes_count'),
];
});
return view('livewire.playlists', [
'playlists' => $playlists,
'totalPlaylists' => $stats['playlists'],
'totalEpisodes' => $stats['episodes'],
]);
}
}
+2
View File
@@ -18,6 +18,8 @@ class Downloads extends Model
'episode_id',
'type',
'url',
'size',
'validated_at',
];
/**
+10
View File
@@ -2,12 +2,22 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Gallery extends Model
{
use HasFactory;
public $table = 'gallery';
/**
* The attributes that aren't mass assignable.
*
* @var array<int, string>
*/
protected $guarded = [];
/**
* Belongs To Episode.
*/
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Playlist extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlaylistEpisode extends Model
{
use HasFactory;
/**
* Indicates If The Model Should Be Timestamped.
*
+8 -2
View File
@@ -14,9 +14,15 @@ class CommentPresenter
$this->comment = $comment;
}
public function markdownBody()
public function markdownBody(?int $limit = null)
{
return Str::of($this->comment->body)->markdown([
$body = $this->comment->body;
if ($limit !== null) {
$body = Str::limit($body, $limit);
}
return Str::of($body)->markdown([
'html_input' => 'strip',
]);
}
+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\Hentai;
use App\Models\Studios;
use App\Models\ModLog;
use App\Models\Studios;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EpisodeService
{
@@ -30,39 +32,6 @@ class EpisodeService
return $slug;
}
public function createEpisode(
Request $request,
Hentai $hentai,
int $episodeNumber,
?Studios $studio = null,
?Episode $referenceEpisode = null
): Episode {
$episode = new Episode;
$episode->title = $referenceEpisode->title ?? $request->input('title');
$episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title);
$episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn');
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $referenceEpisode->studio->id ?? $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}");
$episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->is_dvd_aspect = false;
$episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
// Tagging
$tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags'));
foreach ($tags as $t) {
$episode->tag($referenceEpisode ? $t->name : $t->value);
}
return $episode;
}
private function applyTags(Request $request, Episode $episode): void
{
$tags = json_decode($request->input('tags'));
@@ -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
{
if (! $request->hasFile("episodecover{$episodeNumber}")) {
return;
}
$this->saveCoverFromFile($episode, $slug, $episodeNumber, $request->file("episodecover{$episodeNumber}"));
}
public function saveCoverFromFile(Episode $episode, string $slug, int $episodeNumber, UploadedFile|TemporaryUploadedFile $file): void
{
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
}
// Encode and save cover image
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath())
Image::read($file->getRealPath())
->cover(268, 394)
->encode(new WebpEncoder)
->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\Hentai;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class GalleryService
{
@@ -17,19 +19,28 @@ class GalleryService
$galleryInputNumber = $override ? 1 : $episodeNumber;
if ($request->hasFile('episodegallery'.$galleryInputNumber)) {
$this->saveGalleryFiles($hentai, $episode, $episodeNumber, $request->file('episodegallery'.$galleryInputNumber));
}
}
/**
* Save a gallery for the given episode from a list of uploaded files.
*
* @param array<int, UploadedFile|TemporaryUploadedFile> $files
*/
public function saveGalleryFiles(Hentai $hentai, Episode $episode, int $episodeNumber, array $files): void
{
$this->deleteOldGallery($episode);
$this->createGalleryFolder($hentai);
$counter = 0;
foreach ($request->file('episodegallery'.$galleryInputNumber) as $file) {
foreach ($files as $file) {
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
$this->saveGalleryImage($gallery, $file);
$counter += 1;
}
}
}
private function createGalleryFolder(Hentai $hentai): void
{
@@ -51,7 +62,7 @@ class GalleryService
return $gallery;
}
private function saveGalleryImage(Gallery $gallery, $sourceImage): void
private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void
{
Image::read($sourceImage->getRealPath())
->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": {
"barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.24.0",
"laravel/boost": "^2.4",
"laravel/breeze": "^2.3",
"laravel/pint": "^1.18",
"mockery/mockery": "^1.4.4",
Generated
+278 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bfeb75482defef9826c45252382f7e6d",
"content-hash": "636d4675deabe7605859a7873be3d200",
"packages": [
{
"name": "altcha-org/altcha",
@@ -10138,6 +10138,72 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "laravel/boost",
"version": "v2.4.13",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "f55e08f5afa89ac72f23f574175005b67878f466"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/f55e08f5afa89ac72f23f574175005b67878f466",
"reference": "f55e08f5afa89ac72f23f574175005b67878f466",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2026-07-17T14:28:57+00:00"
},
{
"name": "laravel/breeze",
"version": "v2.4.2",
@@ -10199,6 +10265,80 @@
},
"time": "2026-05-14T16:54:25+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.9.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "a08884d79a95c5143498507aec5badf751cdbec4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/a08884d79a95c5143498507aec5badf751cdbec4",
"reference": "a08884d79a95c5143498507aec5badf751cdbec4",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2",
"symfony/process": "^7.4.5|^8.0.5"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-07-21T13:23:52+00:00"
},
{
"name": "laravel/pint",
"version": "v1.29.1",
@@ -10267,6 +10407,67 @@
},
"time": "2026-04-20T15:26:14+00:00"
},
{
"name": "laravel/roster",
"version": "v0.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/routing": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2026-03-05T07:58:43+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.12",
@@ -12578,6 +12779,82 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/yaml",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "8e4cdd4311683516be06944f4b85244063cdb886"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886",
"reference": "8e4cdd4311683516be06944f4b85244063cdb886",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<7.4"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"yaml/yaml-test-suite": "*"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-09T11:06:24+00:00"
},
{
"name": "theseer/tokenizer",
"version": "1.3.1",
+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 [
'home' => 'Startseite',
'browse' => 'Stöbern',
'community' => 'Community',
'search' => 'Suche',
'public-playlists' => 'Öffentliche Playlisten',
'downloads' => 'Downloads',
+13
View File
@@ -2,8 +2,21 @@
return [
'no-playlist-found' => 'Keine Playlisten gefunden!',
'hero-subtitle' => 'Kuratierte Episodensammlungen von der Community erstellt.',
'by' => 'von',
'playlists-count' => 'Playlisten',
'clear-search' => 'Suche löschen',
'personal-playlists' => 'Persönliche Playlisten',
'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.',
'play' => 'Abspielen',
'playlist' => 'Playlist',
'search-episodes' => 'Episoden durchsuchen',
'per-page' => 'Pro Seite',
'episodes' => 'Episoden',
'empty-playlist' => 'Diese Playlist ist leer.',
'no-matches' => 'Keine Episoden entsprechen deiner Suche.',
'watched' => 'Gesehen',
'remove' => 'Entfernen',
'now-playing' => 'Jetzt läuft',
'remove-confirm' => 'Diese Episode aus der Playlist entfernen?',
];
+2
View File
@@ -2,6 +2,8 @@
return [
'home' => 'Home',
'browse' => 'Browse',
'community' => 'Community',
'search' => 'Search',
'public-playlists' => 'Public Playlists',
'downloads' => 'Downloads',
+13
View File
@@ -2,8 +2,21 @@
return [
'no-playlist-found' => 'No Playlist found!',
'hero-subtitle' => 'Curated episode collections made by the community.',
'by' => 'by',
'playlists-count' => 'Playlists',
'clear-search' => 'Clear search',
'personal-playlists' => 'Personal Playlists',
'create-on-personal-page' => 'You can create one in your personal playlists page.',
'play' => 'Play',
'playlist' => 'Playlist',
'search-episodes' => 'Search episodes',
'per-page' => 'Per page',
'episodes' => 'Episodes',
'empty-playlist' => 'This playlist is empty.',
'no-matches' => 'No episodes match your search.',
'watched' => 'Watched',
'remove' => 'Remove',
'now-playing' => 'Now Playing',
'remove-confirm' => 'Remove this episode from the playlist?',
];
+2
View File
@@ -2,6 +2,8 @@
return [
'home' => 'Accueil',
'browse' => 'Parcourir',
'community' => 'Communauté',
'search' => 'Recherche',
'public-playlists' => 'Playlists publiques',
'downloads' => 'Téléchargements',
+13
View File
@@ -2,8 +2,21 @@
return [
'no-playlist-found' => 'Aucune playlist n\'a été trouvée!',
'hero-subtitle' => 'Collections d\'épisodes organisées par la communauté.',
'by' => 'par',
'playlists-count' => 'Playlists',
'clear-search' => 'Effacer la recherche',
'personal-playlists' => 'Playlists personnelles',
'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.',
'play' => 'Lire',
'playlist' => 'Playlist',
'search-episodes' => 'Rechercher des épisodes',
'per-page' => 'Par page',
'episodes' => 'Épisodes',
'empty-playlist' => 'Cette playlist est vide.',
'no-matches' => 'Aucun épisode ne correspond à votre recherche.',
'watched' => 'Vu',
'remove' => 'Supprimer',
'now-playing' => 'En lecture',
'remove-confirm' => 'Supprimer cet épisode de la playlist ?',
];
+5 -2
View File
@@ -19,12 +19,15 @@
</source>
<php>
<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="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="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="SCOUT_DRIVER" value="collection"/>
<env name="ALTCHA_HMAC_KEY" value="testing-altcha-hmac-key"/>
</php>
</phpunit>
+36
View File
@@ -68,6 +68,10 @@ input:checked~.dot {
transform: translateX(100%);
}
input:checked~.theme-icon {
transform: rotate(90deg);
}
#plyr__time_skip {
background: #c61e54;
border: 0;
@@ -146,3 +150,35 @@ input:checked~.dot {
:root {
color-scheme: light dark;
}
/* Stats Page - Shimmer Skeleton Loader */
.shimmer-overlay {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
pointer-events: none;
}
.dark .shimmer-overlay {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 100%
);
background-size: 200% 100%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
+66
View File
@@ -0,0 +1,66 @@
import Tagify from '@yaireo/tagify';
import '@yaireo/tagify/dist/tagify.css';
document.addEventListener('livewire:init', () => {
const Alpine = window.Alpine;
const dropdownOptions = {
classname: "color-blue",
enabled: 0,
maxItems: 10,
position: "text",
closeOnSelect: false,
highlightFirst: true
};
Alpine.data('adminReleaseTags', () => ({
tagify: null,
init() {
const el = this.$refs.tags;
window.axios.get('/admin/tags').then((response) => {
if (response.status != 200) {
return;
}
this.tagify = new Tagify(el, {
whitelist: response.data.tags,
dropdown: dropdownOptions
});
this.tagify.on('change', () => {
this.$wire.set('tags', this.tagify.value.map((t) => t.value));
});
}).catch((error) => {
console.log(error);
});
}
}));
Alpine.data('adminReleaseStudio', () => ({
tagify: null,
init() {
const el = this.$refs.studio;
window.axios.get('/admin/studios').then((response) => {
if (response.status != 200) {
return;
}
this.tagify = new Tagify(el, {
whitelist: response.data.studios,
dropdown: dropdownOptions
});
this.tagify.on('change', () => {
const values = this.tagify.value.map((t) => t.value);
this.$wire.set('studio', values.length ? values[values.length - 1] : '');
});
}).catch((error) => {
console.log(error);
});
}
}));
});
-104
View File
@@ -14,107 +14,3 @@ export function playNextPlaylistVideo() {
window.location.href = '/hentai/' + nextEpisode + '?playlist=' + playlistId;
}
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();
});
+274 -34
View File
@@ -1,73 +1,313 @@
import Chart from 'chart.js/auto';
// Theming
if (localStorage.theme !== 'light') {
Chart.defaults.color = "#ADBABD";
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
/**
* Theme-aware chart defaults
*/
function getChartColors() {
const isDark = localStorage.theme !== 'light' &&
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
return {
textColor: '#ADBABD',
gridColor: 'rgba(255, 255, 255, 0.06)',
fillStart: 'rgba(190, 18, 60, 0.2)',
fillEnd: 'rgba(190, 18, 60, 0.0)',
borderColor: 'rgba(190, 18, 60, 0.9)',
pointColor: 'rgba(190, 18, 60, 1)',
pointHoverColor: '#ffffff',
skeletonBase: '#262626',
skeletonShimmer: '#333333',
};
}
// Get Tags from API
window.axios.get('/v1/monthly-views').then(function (response) {
if (response.status != 200) {
return;
return {
textColor: '#6B7280',
gridColor: 'rgba(0, 0, 0, 0.06)',
fillStart: 'rgba(190, 18, 60, 0.15)',
fillEnd: 'rgba(190, 18, 60, 0.0)',
borderColor: 'rgba(190, 18, 60, 1.0)',
pointColor: 'rgba(190, 18, 60, 1)',
pointHoverColor: '#ffffff',
skeletonBase: '#E5E7EB',
skeletonShimmer: '#F3F4F6',
};
}
const data = {
labels: response.data.map((entry) => { return entry.date }),
/**
* Show the skeleton loader
*/
function showSkeleton() {
const skeleton = document.getElementById('chart-skeleton');
const canvas = document.getElementById('monthlyChart');
const error = document.getElementById('chart-error');
if (skeleton) skeleton.style.display = '';
if (canvas) canvas.style.opacity = '0';
if (error) error.classList.add('hidden');
}
/**
* Hide the skeleton and show the chart canvas
*/
function hideSkeleton() {
const skeleton = document.getElementById('chart-skeleton');
const canvas = document.getElementById('monthlyChart');
if (skeleton) {
// Fade out skeleton
skeleton.style.transition = 'opacity 0.4s ease-out';
skeleton.style.opacity = '0';
setTimeout(() => {
if (skeleton) skeleton.style.display = 'none';
}, 400);
}
if (canvas) {
setTimeout(() => {
canvas.style.opacity = '1';
}, 200);
}
}
/**
* Show error state
*/
function showError() {
const skeleton = document.getElementById('chart-skeleton');
const error = document.getElementById('chart-error');
if (skeleton) skeleton.style.display = 'none';
if (error) {
error.classList.remove('hidden');
error.style.display = 'flex';
}
}
/**
* Hide error state
*/
function hideError() {
const error = document.getElementById('chart-error');
if (error) {
error.classList.add('hidden');
error.style.display = 'none';
}
}
/**
* Create gradient fill for the chart
*/
function createGradient(ctx, colors) {
const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight);
gradient.addColorStop(0, colors.fillStart);
gradient.addColorStop(1, colors.fillEnd);
return gradient;
}
let monthlyViewChart = null;
/**
* Render the chart with data
*/
function renderChart(data) {
const colors = getChartColors();
const canvas = document.getElementById('monthlyChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Destroy previous chart instance if it exists
if (monthlyViewChart) {
monthlyViewChart.destroy();
monthlyViewChart = null;
}
const gradient = createGradient(ctx, colors);
const chartData = {
labels: data.map((entry) => entry.date),
datasets: [{
label: 'Views',
fill: false,
backgroundColor: 'rgba(190, 18, 60, 0.3)',
borderColor: 'rgba(190, 18, 60, 1.0)',
fill: true,
backgroundColor: gradient,
borderColor: colors.borderColor,
borderWidth: 2.5,
pointBackgroundColor: colors.pointColor,
pointBorderColor: colors.pointColor,
pointHoverBackgroundColor: colors.pointHoverColor,
pointHoverBorderColor: colors.borderColor,
pointHoverBorderWidth: 2,
pointHoverRadius: 6,
pointRadius: 2.5,
pointHitRadius: 20,
cubicInterpolationMode: 'monotone',
data: response.data.map((entry) => { return entry.count }),
tension: 0.4,
data: data.map((entry) => entry.count),
}]
}
};
const config = {
type: 'line',
data: data,
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1200,
easing: 'easeOutQuart',
},
plugins: {
title: {
display: true,
text: 'Views the last 28 days',
font: {
size: 18
display: false,
},
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(17, 17, 17, 0.95)',
titleColor: '#ffffff',
bodyColor: '#D1D5DB',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
cornerRadius: 10,
displayColors: false,
bodyFont: {
size: 13,
},
titleFont: {
size: 12,
weight: '600',
},
callbacks: {
label: function(context) {
return 'Views: ' + new Intl.NumberFormat().format(context.parsed.y);
},
title: function(context) {
return context[0].label;
}
}
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: {
x: {
display: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
maxTicksLimit: 14,
maxRotation: 0,
},
title: {
display: true
display: false,
}
},
y: {
display: true,
title: {
display: true,
text: 'Views'
beginAtZero: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
callback: function(value) {
if (value >= 1000000) return (value / 1000000).toFixed(1) + 'M';
if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
return value;
},
},
title: {
display: false,
},
suggestedMin: 0,
suggestedMax: 40000
}
}
},
};
const monthlyViewChart = new Chart(
document.getElementById('monthlyChart'),
config
);
}).catch(function (error) {
console.log(error);
hideError();
monthlyViewChart = new Chart(canvas, config);
hideSkeleton();
}
/**
* Fetch chart data from the API
*/
async function fetchChartData() {
showSkeleton();
hideError();
try {
const response = await window.axios.get('/v1/monthly-views');
if (response.status !== 200 || !response.data || response.data.length === 0) {
throw new Error('Invalid or empty response');
}
renderChart(response.data);
} catch (error) {
console.error('Failed to load chart data:', error);
showError();
}
}
/**
* Retry loading the chart (called from error state button)
*/
window.retryChart = function() {
if (monthlyViewChart) {
monthlyViewChart.destroy();
monthlyViewChart = null;
}
fetchChartData();
};
// Listen for theme changes to re-render chart
const themeObserver = new MutationObserver(() => {
if (monthlyViewChart) {
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
date: monthlyViewChart.data.labels[index],
count: value,
}));
renderChart(data);
}
});
// Observe theme class changes on html element
const htmlElement = document.documentElement;
if (htmlElement) {
themeObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] });
}
// Start the fetch when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Small delay to ensure the page has rendered and skeleton is visible
setTimeout(() => {
fetchChartData();
}, 300);
});
// Handle window resize for theme changes (system preference)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!('theme' in localStorage) && monthlyViewChart) {
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
date: monthlyViewChart.data.labels[index],
count: value,
}));
renderChart(data);
}
});
+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 == 'light') {
@@ -15,10 +15,9 @@ if(localStorage.theme) {
document.querySelector("html").classList.toggle("dark");
}
const toggleThemeButton = document.getElementById("toogleTheme");
if (toggleThemeButton) {
toggleThemeButton.checked = true;
}
document.querySelectorAll("#toogleTheme").forEach((toggle) => {
toggle.checked = true;
});
}
} else {
@@ -28,7 +27,6 @@ if(localStorage.theme) {
// Ability to disable blur effects for slower devices
const LOCAL_STORAGE_KEY = 'blur';
const blurCheckbox = document.querySelector("input[type='checkbox']#toggleBlur");
function setCSSFilter(selector, value) {
document.querySelectorAll(selector).forEach(el => {
@@ -38,10 +36,12 @@ function setCSSFilter(selector, value) {
function applyBlur(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;
}
setCSSFilter('.backdrop-blur-2xl', 'blur(40px)');
setCSSFilter('.backdrop-blur-xl', 'blur(24px)');
setCSSFilter('.backdrop-blur-lg', 'blur(16px)');
setCSSFilter('.backdrop-blur', 'blur(8px)');
setCSSFilter('.backdrop-blur-sm', 'blur(4px)');
@@ -51,19 +51,22 @@ function initBlurToggle() {
const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY);
const enabled = storedValue === null ? true : storedValue === 'true';
const blurCheckboxes = document.querySelectorAll("input[type='checkbox']#toggleBlur");
// initialize UI and DOM
applyBlur(enabled);
if (blurCheckbox) blurCheckbox.checked = enabled;
blurCheckboxes.forEach((checkbox) => {
checkbox.checked = enabled;
});
// add event listener
if (blurCheckbox) {
blurCheckbox.addEventListener('click', (e) => {
console.log("Received Event");
blurCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('click', (e) => {
const isEnabled = e.target.checked;
applyBlur(isEnabled);
localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false');
});
}
});
}
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">
@include('layouts.navigation')
@include('partials.background')
<div class="mt-[65px]">
<div class="mt-[80px]">
@include('admin.partials.sidenav')
<div class="pl-64">
@yield('content')
@@ -6,76 +6,8 @@
<!--Modal body-->
<div class="relative p-4 pt-0">
<form method="POST" action="{{ route('admin.upload.episode') }}" enctype="multipart/form-data">
@csrf
<input name="episode_id" id="episode_id" type="hidden"/>
<!-- Episodes -->
<div class="grid grid-cols-2">
<!-- Cover -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover1">Cover:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodecover1" id="episodecover1" required>
</div>
<!-- Thumbs -->
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery1">Gallery:</label>
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodegallery1[]" id="episodegallery1" multiple="">
</div>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description">Description:</label>
<textarea rows="4" cols="50" id="description" name="description" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required>{{ $episode->description }}</textarea>
</div>
<div class="p-4">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="baseurl">Stream:</label>
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" required />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p:</label>
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" required />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated1">Download 1080p 48fps:</label>
<x-text-input id="episodedlurlinterpolated1" class="block w-full" type="text" name="episodedlurlinterpolated1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k:</label>
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi1">Download 4k 48fps:</label>
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" />
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel
</button>
<button type="submit" class="ml-1 inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
Add
</button>
</div>
</form>
@livewire('admin-episode-form', ['episodeId' => $episode->id])
</div>
</div>
</div>
<!-- Modals JS -->
<script>
document.getElementById('episode_id').value = document.getElementById('e_id').value;
</script>
</div>
+3 -107
View File
@@ -1,111 +1,7 @@
@extends('admin.layout')
@section('content')
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
<div class="flex items-center justify-center">
<div class="relative p-4 pt-0 bg-white dark:bg-neutral-800 rounded-lg border-t-2 border-b-2 border-pink-700">
<form method="POST" action="{{ route('admin.upload') }}" enctype="multipart/form-data">
@csrf
<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'])
@livewire('admin-release-form')
@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
$alignmentClasses = match ($align) {
@@ -9,6 +9,9 @@ $alignmentClasses = match ($align) {
$width = match ($width) {
'48' => 'w-48',
'56' => 'w-56',
'64' => 'w-64',
'72' => 'w-72',
default => $width,
};
@endphp
@@ -25,10 +28,10 @@ $width = match ($width) {
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
class="absolute z-50 mt-2 {{ $width }} rounded-2xl shadow-lg {{ $alignmentClasses }}"
style="display: none;"
@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 }}
</div>
</div>
@@ -1,11 +1,15 @@
@props(['active'])
@props(['active' => false, 'label' => null])
@php
$classes = ($active ?? false)
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out';
$classes = $active
? 'inline-flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-rose-600 to-pink-600 text-white shadow-md shadow-rose-600/25 max-lg:w-9 lg:px-3.5 [&>i]:shrink-0'
: 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-600 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-gray-300 dark:ring-neutral-700 dark:hover:bg-rose-950/30 dark:hover:text-rose-300';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
<a {{ $attributes->merge(['class' => $classes]) }} @if ($label && ! $active) title="{{ $label }}" @endif>
{{ $slot }}
@if ($active && $label)
<span class="hidden truncate text-sm font-semibold lg:inline-flex">{{ $label }}</span>
@endif
</a>
@@ -0,0 +1,17 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-16">
<div class="flex flex-col md:flex-row gap-6 md:gap-8">
{{-- Sidebar --}}
<div class="w-full md:w-64 xl:w-72 shrink-0">
@include('profile.partials.sidebar')
</div>
{{-- Content --}}
<div class="flex-1 min-w-0">
{{ $slot }}
</div>
</div>
</div>
</x-app-layout>
@@ -2,8 +2,8 @@
@php
$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'
: '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-rose-500 bg-rose-50/80 dark:bg-rose-950/30 px-4 py-2.5 text-sm font-semibold text-rose-700 dark:text-rose-300'
: 'flex w-full items-center gap-3 rounded-xl border-l-4 border-transparent px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-rose-50/60 dark:hover:bg-rose-950/20 hover:text-rose-700 dark:hover:text-rose-300 transition-colors';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
+352 -65
View File
@@ -1,78 +1,365 @@
<x-app-layout>
<div class="container mx-auto px-4 py-12 md:py-24">
<section class="text-center mb-16">
<!-- Logo -->
<div class="flex justify-center mb-8">
<div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
{{-- Header Section --}}
<section class="text-center mb-10 md:mb-14">
<div class="flex justify-center mb-6">
<img
src="/images/cropped-HS-1-270x270.webp"
alt="hstream.moe Logo"
class="max-w-[150px] w-full h-auto rounded-lg"
class="max-w-[120px] w-full h-auto rounded-xl shadow-lg hover:scale-105 transition-transform duration-300"
/>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
<!-- View Count Card -->
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-eye text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ number_format($viewCount) }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
total views
</h5>
</div>
<!-- Episode Count Card -->
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-video text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ $episodeCount }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
episodes on this site
</h5>
</div>
<!-- Hentai Count Card -->
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-list text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ $hentaiCount }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
hentais on this site
</h5>
</div>
<!-- Watch Time Card -->
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-clock text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ number_format($viewCount * 6) }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
estimated minutes of watch time
</h5>
</div>
</div>
<!-- Chart Container -->
<div class="mt-12 mx-auto max-w-4xl">
<div class="bg-gray-50 dark:bg-neutral-950 rounded-xl p-4 md:p-6 shadow-inner hidden sm:block">
<canvas id="monthlyChart" class="w-full h-64 md:h-80"></canvas>
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
Site Statistics
</h1>
<p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
A comprehensive overview of hstream.moe's content and community activity
</p>
<div class="mt-5 flex justify-center">
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Live data · Updated hourly
</div>
</div>
</section>
{{-- Primary Stats Grid --}}
<section class="mb-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Total Views --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-sky-400/10 dark:bg-sky-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center">
<i class="fa-solid fa-eye text-sky-600 dark:text-sky-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $viewCount }}">
{{ number_format($viewCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Views</p>
</div>
</div>
{{-- Total Episodes --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-violet-400/10 dark:bg-violet-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/50 flex items-center justify-center">
<i class="fa-solid fa-video text-violet-600 dark:text-violet-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $episodeCount }}">
{{ number_format($episodeCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Episodes</p>
</div>
</div>
{{-- Total Series --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-rose-400/10 dark:bg-rose-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-rose-100 dark:bg-rose-900/50 flex items-center justify-center">
<i class="fa-solid fa-list text-rose-600 dark:text-rose-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $hentaiCount }}">
{{ number_format($hentaiCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Series</p>
</div>
</div>
{{-- Total Users --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-400/10 dark:bg-emerald-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
<i class="fa-solid fa-users text-emerald-600 dark:text-emerald-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $userCount }}">
{{ number_format($userCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Registered Users</p>
</div>
</div>
</div>
</section>
{{-- Secondary Stats Grid --}}
<section class="mb-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Total Likes --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
<i class="fa-solid fa-heart text-red-500 dark:text-red-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $likeCount }}">
{{ number_format($likeCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Likes</p>
</div>
</div>
{{-- Total Comments --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<i class="fa-solid fa-comments text-amber-600 dark:text-amber-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $commentCount }}">
{{ number_format($commentCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Comments</p>
</div>
</div>
{{-- Total Downloads --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
<i class="fa-solid fa-download text-blue-600 dark:text-blue-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $downloadCount }}">
{{ number_format($downloadCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Downloads</p>
</div>
</div>
{{-- Avg Views Per Episode --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/50 flex items-center justify-center">
<i class="fa-solid fa-chart-simple text-orange-600 dark:text-orange-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $avgViewsPerEpisode }}">
{{ number_format($avgViewsPerEpisode) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Avg Views / Episode</p>
</div>
</div>
</div>
</section>
{{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}}
<section class="mb-10">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Today's Views --}}
<div class="relative overflow-hidden bg-gradient-to-br from-sky-50 to-sky-100/50 dark:from-sky-950/40 dark:to-sky-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-sky-200/50 dark:border-sky-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-sky-600 dark:text-sky-400 font-semibold uppercase tracking-wider mb-2">Today</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $todayViews }}">
{{ number_format($todayViews) }}
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">views so far</p>
</div>
</div>
{{-- This Week's Views with Trend --}}
<div class="relative overflow-hidden bg-gradient-to-br from-violet-50 to-violet-100/50 dark:from-violet-950/40 dark:to-violet-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-violet-200/50 dark:border-violet-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-violet-600 dark:text-violet-400 font-semibold uppercase tracking-wider mb-2">Views This Week</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $weeklyViews }}">
{{ number_format($weeklyViews) }}
</div>
<div class="flex items-center gap-1">
@php
$trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0;
@endphp
@if($trend > 0)
<i class="fa-solid fa-arrow-trend-up text-green-500 text-xs"></i>
<span class="text-xs text-green-600 dark:text-green-400 font-semibold">{{ $trend }}% vs last week</span>
@elseif($trend < 0)
<i class="fa-solid fa-arrow-trend-down text-red-500 text-xs"></i>
<span class="text-xs text-red-600 dark:text-red-400 font-semibold">{{ abs($trend) }}% vs last week</span>
@else
<span class="text-xs text-gray-500 dark:text-neutral-400 font-medium">same as last week</span>
@endif
</div>
</div>
</div>
{{-- New Episodes This Week --}}
<div class="relative overflow-hidden bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-950/40 dark:to-emerald-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-emerald-200/50 dark:border-emerald-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider mb-2">New This Week</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $newEpisodesThisWeek }}">
{{ number_format($newEpisodesThisWeek) }}
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">episodes added</p>
</div>
</div>
{{-- 4K / 48fps Content --}}
<div class="relative overflow-hidden bg-gradient-to-br from-rose-50 to-rose-100/50 dark:from-rose-950/40 dark:to-rose-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-rose-200/50 dark:border-rose-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wider mb-2">High Quality</p>
<div class="flex items-baseline gap-2 mb-1">
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodes4k }}">{{ number_format($episodes4k) }}</span>
<span class="text-sm text-gray-500 dark:text-neutral-400">4K</span>
<span class="text-gray-300 dark:text-neutral-700">/</span>
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodesUHD48 }}">{{ number_format($episodesUHD48) }}</span>
<span class="text-sm text-gray-500 dark:text-neutral-400">48fps</span>
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">4K & 48fps content</p>
</div>
</div>
</div>
</section>
{{-- Views Chart Section --}}
<section class="mb-10">
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-bold text-gray-900 dark:text-white">Views Over Time</h2>
<p class="text-xs text-gray-500 dark:text-neutral-400">Daily views for the past 28 days</p>
</div>
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
</span>
</div>
</div>
{{-- Chart wrapper with skeleton loader --}}
<div class="relative min-h-[300px] md:min-h-[350px]">
{{-- Skeleton Loader --}}
<div id="chart-skeleton" class="absolute inset-0 flex flex-col justify-end px-1 pb-6 z-10">
<div class="flex items-end justify-between gap-1 md:gap-2 h-full">
@for ($i = 0; $i < 28; $i++)
@php
$heights = [55, 40, 65, 35, 60, 45, 70, 50, 75, 38, 58, 42, 80, 48, 68, 33, 62, 44, 72, 52, 55, 40, 65, 58, 78, 46, 63, 50];
$h = $heights[$i % count($heights)];
@endphp
<div class="flex-1 flex flex-col items-center justify-end gap-1">
<div class="w-full rounded-md bg-gray-200 dark:bg-neutral-800 animate-pulse" style="height: {{ $h }}%; min-height: 8px;"></div>
</div>
@endfor
</div>
{{-- Shimmer overlay --}}
<div class="absolute inset-0 shimmer-overlay rounded-lg"></div>
</div>
{{-- Actual Chart Canvas --}}
<canvas id="monthlyChart" class="w-full h-[300px] md:h-[350px] relative z-0 opacity-0 transition-opacity duration-500"></canvas>
{{-- Error State --}}
<div id="chart-error" class="absolute inset-0 hidden flex-col items-center justify-center bg-white/90 dark:bg-neutral-900/90 rounded-lg z-20">
<div class="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-3">
<i class="fa-solid fa-triangle-exclamation text-red-500 text-xl"></i>
</div>
<p class="text-sm text-gray-600 dark:text-neutral-300 font-medium">Unable to load chart data</p>
<button onclick="retryChart()" class="mt-3 px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-700 rounded-lg transition-colors">
<i class="fa-solid fa-rotate mr-1"></i> Retry
</button>
</div>
</div>
{{-- Mobile legend --}}
<div class="flex sm:hidden items-center justify-center gap-3 mt-3 text-xs text-gray-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
</span>
</div>
</div>
</section>
{{-- Bottom Section: Top Tags --}}
@if($topTags->isNotEmpty())
<section class="mb-8">
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Most Popular Tags</h2>
<div class="flex flex-wrap gap-2">
@foreach($topTags as $tag)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold
bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-neutral-300
hover:bg-rose-100 dark:hover:bg-rose-900/40 hover:text-rose-700 dark:hover:text-rose-400
transition-colors duration-200 cursor-default">
#{{ $tag->name }}
<span class="text-[10px] text-gray-400 dark:text-neutral-500">{{ number_format($tag->count) }}</span>
</span>
@endforeach
</div>
</div>
</section>
@endif
{{-- Footer Note --}}
<div class="text-center text-xs text-gray-400 dark:text-neutral-600">
Statistics are cached and update periodically. Data shown may not reflect real-time changes.
</div>
</div>
{{-- Animated Counter Script --}}
<script>
document.addEventListener('DOMContentLoaded', () => {
const animateCounters = () => {
document.querySelectorAll('.stat-counter').forEach(counter => {
const target = parseInt(counter.dataset.target, 10);
if (isNaN(target)) return;
const duration = 1500;
const start = performance.now();
const startVal = 0;
const step = (currentTime) => {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(startVal + (target - startVal) * eased);
const formatted = new Intl.NumberFormat().format(current);
counter.textContent = formatted;
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
});
};
// Use IntersectionObserver to trigger counters when visible
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCounters();
observer.disconnect();
}
});
}, { threshold: 0.3 });
const firstCounter = document.querySelector('.stat-counter');
if (firstCounter) {
observer.observe(firstCounter);
} else {
// Fallback
animateCounters();
}
});
</script>
@vite(['resources/js/stats.js'])
</x-app-layout>
+2 -2
View File
@@ -9,7 +9,7 @@
<!-- Page Heading -->
@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">
{{ $header }}
</div>
@@ -17,7 +17,7 @@
@endif
<!-- 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>
{{ $slot }}
</main>
+229 -163
View File
@@ -1,59 +1,64 @@
<nav x-data="{ open: false }"
class="bg-white/30 dark:bg-neutral-950/40 border-b border-gray-200 dark:border-neutral-700 fixed xs:absolute sm:fixed w-[100%] z-50 backdrop-blur">
<!-- Primary Navigation Menu -->
<div class="max-w-[100%] xl:max-w-[95%] 2xl:max-w-[84%] mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<x-dropdown align="right" width="48">
<nav x-data="{ open: false }" class="fixed inset-x-0 top-0 z-50">
@php $notAvailable = auth()->check() ? auth()->user()->unreadNotifications()->count() > 0 : false; @endphp
<div class="mx-auto max-w-[100%] px-3 pt-3 sm:px-5 xl:max-w-[95%] 2xl:max-w-[84%]">
<div class="grid h-16 grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-2xl border border-gray-200/60 bg-white/70 px-3 backdrop-blur-xl shadow-lg shadow-black/5 dark:border-white/10 dark:bg-neutral-950/60 dark:shadow-black/40 sm:gap-3 sm:px-5">
{{-- Left column: brand + inline links --}}
<div class="flex min-w-0 items-center justify-self-start gap-1">
<a href="{{ route('home.index') }}" class="flex shrink-0 items-center gap-2.5">
<span class="relative">
<img src="/images/cropped-HS-1-192x192.webp" class="h-9 w-9 rounded-xl " alt="hstream.moe Logo" />
</span>
<span class="text-lg font-bold bg-gradient-to-r from-rose-600 to-pink-500 bg-clip-text text-transparent md:hidden xl:block truncate">hstream.moe</span>
</a>
<div class="hidden min-w-0 items-center gap-1 md:flex">
<x-nav-link :href="route('home.index')" :active="request()->routeIs('home.index')" :label="__('nav.home')">
<i class="fa-solid fa-house"></i>
</x-nav-link>
<x-nav-link :href="route('hentai.search')" :active="request()->routeIs('hentai.search', 'hentai.searchredirect')" :label="__('nav.browse')">
<i class="fa-solid fa-compass"></i>
</x-nav-link>
<x-nav-link :href="route('playlist.index')" :active="request()->routeIs('playlist.*')" :label="__('nav.public-playlists')">
<i class="fa-solid fa-rectangle-list"></i>
</x-nav-link>
@auth
<x-nav-link :href="route('download.search')" :active="request()->routeIs('download.search')" :label="__('nav.downloads')">
<i class="fa-solid fa-download"></i>
</x-nav-link>
@endauth
</div>
</div>
{{-- Center column: live search --}}
<div class="hidden items-center justify-self-center md:flex">
@livewire('nav-live-search')
</div>
{{-- Right column: right cluster + hamburger --}}
<div class="flex min-w-0 items-center justify-self-end col-start-3 gap-1.5">
<div class="hidden items-center gap-1.5 md:flex">
{{-- Community dropdown --}}
<x-dropdown align="right" width="56">
<x-slot name="trigger">
<button
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-700 border-neutral-300/50 dark:text-gray-200 bg-white/20 dark:bg-neutral-950/20 dark:border-neutral-800/50 hover:text-gray-800 dark:hover:text-gray-100 focus:outline-none transition ease-in-out duration-150">
<div class="shrink-0 flex items-center">
<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 type="button" title="{{ __('nav.community') }}"
class="inline-flex h-9 w-9 items-center justify-center rounded-full ring-1 ring-gray-200 text-gray-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:ring-neutral-700 dark:text-gray-300 dark:hover:bg-rose-950/40 dark:hover:text-rose-300">
<i class="fa-solid fa-users"></i>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('home.index')">
<i class="fa-solid fa-house"></i> {{ __('nav.home') }}
</x-dropdown-link>
<x-dropdown-link :href="route('hentai.search')">
<i class="fa-solid fa-magnifying-glass"></i> {{ __('nav.search') }}
</x-dropdown-link>
<x-dropdown-link :href="route('playlist.index')">
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.public-playlists') }}
</x-dropdown-link>
@auth
<x-dropdown-link :href="route('download.search')">
<i class="fa-solid fa-download"></i> {{ __('nav.downloads') }}
</x-dropdown-link>
@endauth
<x-dropdown-link :href="config('discord.invite_link')">
<i class="fa-brands fa-discord"></i> {{ __('nav.our-discord-server') }}
<i class="fa-brands fa-discord text-indigo-500 dark:text-indigo-400"></i> {{ __('nav.our-discord-server') }}
</x-dropdown-link>
<x-dropdown-link :href="route('join.matrix')">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
<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 pr-1">
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" />
@@ -62,91 +67,67 @@
<path d="M17 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
</svg>
Join our Matrix
</div>
</x-dropdown-link>
<x-dropdown-link>
<div class="grid grid-cols-2">
<p class="cursor-default">{{ __('nav.theme') }}</p>
<div class="flex items-center">
<div class="absolute right-6">
@include('partials.themeswitcher')
</div>
</div>
</div>
</x-dropdown-link>
{{-- Expiremental --}}
<x-dropdown-link>
@include('partials.blurswitcher')
<x-dropdown-link :href="route('contact.index')">
<i class="fa-solid fa-message"></i> Contact
</x-dropdown-link>
</x-slot>
</x-dropdown>
</div>
<div class="items-center hidden md:flex">
@livewire('nav-live-search')
<div class="hidden lg:block pl-4">
<div class="flex flex-col items-center bg-gray-50/20 dark:bg-neutral-900/40 rounded-md">
<a href="{{ route('hentai.random') }}"
class="cursor-pointer px-4 py-2 text-sm font-medium dark:hover:text-white text-gray-500 dark:text-white/90 focus:outline-none flex flex-col items-center md:flex-row">
{{-- Random --}}
<a href="{{ route('hentai.random') }}" title="Random"
class="inline-flex h-9 w-9 items-center justify-center rounded-full ring-1 ring-gray-200 text-gray-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:ring-neutral-700 dark:text-gray-300 dark:hover:bg-rose-950/40 dark:hover:text-rose-300">
<i class="fa-solid fa-shuffle"></i>
<p class="md:pl-1 pl-0">Random</p>
</a>
</div>
</div>
</div>
@auth
@php $notAvailable = Auth::user()->unreadNotifications()->count() > 0; @endphp
@else
@php $notAvailable = false; @endphp
@endauth
{{-- Theme toggle --}}
@include('partials.themeswitcher')
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ml-6">
<x-dropdown align="right" width="48">
{{-- Account dropdown --}}
<x-dropdown align="right" width="72">
<x-slot name="trigger">
<button
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-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">
<button type="button"
class="inline-flex items-center gap-2 rounded-full py-1 pl-1 pr-2 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
@auth
<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() }}"
alt="{{ Auth::user()->name }}" />
@else
<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;">
<span class="hidden items-center gap-1.5 text-sm font-semibold text-gray-800 dark:text-neutral-200 xl:inline-flex">
{{ Auth::user()->name }}
@if ($notAvailable)
<span class="relative">
<i class="fa-solid fa-bell text-rose-600"></i>
<span class="absolute -top-1 -right-1 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
</span>
@endif
</div>
</span>
@else
<div style="display: flex; flex-direction: column; align-items: flex-start;">
Guest
<small>{{ __('nav.please-login') }}</small>
</div>
<img class="h-9 w-9 rounded-full object-cover ring-2 ring-rose-500/50" src="/images/default-avatar.webp"
alt="Guest" />
<span class="hidden flex-col items-start leading-tight xl:flex">
<span class="text-sm font-semibold text-gray-800 dark:text-neutral-200">Guest</span>
<small class="text-xs text-gray-400 dark:text-gray-500">{{ __('nav.please-login') }}</small>
</span>
@endauth
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<svg class="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
@auth
<p class="px-3 pb-1 pt-1.5 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<x-dropdown-link :href="route('profile.show')">
<i class="fa-solid fa-user"></i> {{ __('Profile') }}
<i class="fa-solid fa-user"></i> {{ __('nav.profile') }}
</x-dropdown-link>
@if ($notAvailable)
@@ -175,6 +156,10 @@
<i class="fa-solid fa-eye"></i> {{ __('nav.watched') }}
</x-dropdown-link>
<div class="my-1 border-t border-gray-100 dark:border-white/5"></div>
<p class="px-3 pb-1 pt-1.5 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Preferences</p>
<x-dropdown-link :href="route('profile.settings')">
<i class="fa-solid fa-gear"></i> {{ __('nav.settings') }}
</x-dropdown-link>
@@ -184,10 +169,11 @@
<i class="fa-solid fa-user-tie"></i> Admin
</x-dropdown-link>
@endif
@endauth
<!-- Authentication -->
@auth
@include('partials.blurswitcher')
<div class="my-1 border-t border-gray-100 dark:border-white/5"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
@@ -202,7 +188,7 @@
@guest
<x-dropdown-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]">
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') }}
</div>
</x-dropdown-link>
@@ -211,11 +197,11 @@
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-mr-2 flex items-center sm:hidden">
<button @click="open = ! open"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-neutral-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
{{-- Hamburger --}}
<div class="flex items-center md:hidden">
<button @click="open = ! open" type="button"
class="relative inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-500 ring-1 ring-gray-200 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-gray-400 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
<svg class="h-5 w-5" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
@@ -223,101 +209,181 @@
stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@if ($notAvailable)
<span class="absolute mb-4 ml-4 flex h-3 w-3 float-right">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
<span class="absolute -right-0.5 -top-0.5 flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-rose-500"></span>
</span>
@endif
</button>
</div>
</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
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<img class="h-10 w-10 rounded-full object-cover ring-2 ring-rose-500/50"
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')
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600 dark:bg-neutral-900/30">
<div class="flex justify-center">
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" />
<span class="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>
@auth
{{-- Account --}}
<p class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<div class="space-y-1">
@if ($notAvailable)
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell pr-4"></i> Notifications
<span class="relative flex h-3 w-3 float-right top-[6px]">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
<i class="fa-solid fa-bell"></i> Notifications
<span class="relative ml-auto flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-rose-500"></span>
</span>
</x-responsive-nav-link>
@else
<x-responsive-nav-link :href="route('profile.notifications')" :active="request()->routeIs('profile.notifications')">
<i class="fa-solid fa-bell pr-4"></i> Notifications
<i class="fa-solid fa-bell"></i> Notifications
</x-responsive-nav-link>
@endif
<x-responsive-nav-link :href="route('profile.show')" :active="request()->routeIs('profile.show')">
<i class="fa-solid fa-user"></i> {{ __('nav.profile') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.comments')" :active="request()->routeIs('profile.comments')">
<i class="fa-solid fa-comment pr-4"></i> {{ __('nav.comments') }}
<i class="fa-solid fa-comment"></i> {{ __('nav.comments') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.likes')" :active="request()->routeIs('profile.likes')">
<i class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}
<i class="fa-solid fa-heart"></i> {{ __('nav.likes') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('profile.playlists')" :active="request()->routeIs('profile.playlists')">
<i class="fa-solid fa-rectangle-list pr-4"></i> {{ __('nav.playlists') }}
<i class="fa-solid fa-rectangle-list"></i> {{ __('nav.playlists') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('user.watched')" :active="request()->routeIs('user.watched')">
<i class="fa-solid fa-eye pr-4"></i> {{ __('nav.watched') }}
<i class="fa-solid fa-eye"></i> {{ __('nav.watched') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
<x-responsive-nav-link :href="route('profile.settings')" :active="request()->routeIs('profile.settings')">
<i class="fa-solid fa-gear"></i> {{ __('nav.settings') }}
</x-responsive-nav-link>
@if (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<x-responsive-nav-link href="{{ route('admin.upload.index') }}">
<i class="fa-solid fa-user-tie"></i> Admin
</x-responsive-nav-link>
@endif
</div>
{{-- Logout --}}
<form method="POST" action="{{ route('logout') }}" class="pt-4">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
<i class="fa-solid fa-right-from-bracket pr-4"></i> {{ __('nav.logout') }}
<i class="fa-solid fa-right-from-bracket"></i> {{ __('nav.logout') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
@else
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
<!-- Search Form Mobile -->
<div class="pt-2 pb-3 space-y-1">
@include('partials.mobilesearch')
<div class="pb-1 text-center w-full">
<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]">
{{-- Guest login CTA --}}
<div class="pt-4">
<a href="{{ route('login') }}"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 px-4 py-2.5 font-semibold text-white transition-colors hover:from-rose-500 hover:to-pink-500">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</div>
</x-responsive-nav-link>
</div>
</div>
</a>
</div>
@endauth
{{-- Community --}}
<p class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">{{ __('nav.community') }}</p>
<div class="space-y-1">
<x-responsive-nav-link :href="config('discord.invite_link')">
<i class="fa-brands fa-discord text-indigo-500 dark:text-indigo-400"></i> {{ __('nav.our-discord-server') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('join.matrix')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-matrix shrink-0">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 3h-1v18h1" />
<path d="M20 21h1v-18h-1" />
<path d="M7 9v6" />
<path d="M12 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
<path d="M17 15v-3.5a2.5 2.5 0 1 0 -5 0v.5" />
</svg>
Join our Matrix
</x-responsive-nav-link>
</div>
{{-- Toggles --}}
<div class="mt-4 space-y-2 rounded-2xl border border-gray-200/70 p-2 dark:border-white/10">
<div class="flex items-center justify-between gap-3 px-3 py-1.5">
<p class="cursor-default text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('nav.theme') }}</p>
@include('partials.themeswitcher')
</div>
@include('partials.blurswitcher')
</div>
</div>
</nav>
@@ -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>
@@ -1,9 +1,9 @@
<div class="py-10">
<div class="mx-auto sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[90%]">
<div class="mx-auto px-3 sm:px-5 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[84%]">
@include('livewire.partials.search-filter')
</div>
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
<div class="relative pt-5 mx-auto sm:px-6 lg:px-8 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
<div class="relative pt-5 mx-auto px-3 sm:px-5 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[84%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
<div class="flex items-center justify-center">
<div class="flex justify-center">
@@ -3,7 +3,7 @@
@csrf
<div class="relative group">
<label for="live-search" class="sr-only">Search</label>
<div class="relative w-full sm:min-w-[200px] md:min-w-[300px] lg:min-w-[400px] xl:min-w-[500px]">
<div class="relative w-full md:min-w-[220px] lg:min-w-[280px] xl:min-w-[360px]">
{{-- Search Icon --}}
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
@@ -17,7 +17,7 @@
type="search"
id="live-search"
name="live-search"
class="block w-full pl-10 pr-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"
required
@if(request()->path() == 'search') disabled @endif
@@ -39,11 +39,12 @@
aria-live="polite"
>
<div
class="max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-lg transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
<div class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-neutral-800">
class="w-full max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-xl transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-neutral-800">
<div class="text-sm text-gray-700 dark:text-gray-200 font-medium">
@if($episodes->count())
{{ __('Search result for ') }} {{ $query ?: $navSearch }}
{{ __('Search result for ') }}<span class="text-rose-600 dark:text-rose-400 font-semibold">"{{ $query ?: $navSearch }}"</span>
@else
{{ __('No results') }}
@endif
@@ -60,46 +61,105 @@
</div>
</div>
{{-- content area: responsive grid --}}
<div class="p-4">
{{-- Results List --}}
<div>
@if($episodes->count())
<div class="grid grid-cols-1 gap-4 md:grid-cols-1 lg:grid-cols-2">
<ul class="divide-y divide-gray-100 dark:divide-neutral-800" role="listbox">
@foreach($episodes as $episode)
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}" class="group block rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-950 border border-transparent hover:border-gray-200 dark:hover:border-neutral-700 shadow-sm hover:shadow-md transition">
<div class="relative aspect-video">
<li role="option" aria-selected="false">
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}"
class="flex items-center gap-4 px-5 py-2 group/row hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
>
{{-- Left: Cover Image --}}
<div class="flex-shrink-0 relative">
<div class="w-16 h-[6rem] rounded-lg overflow-hidden ring-1 ring-gray-200/80 dark:ring-neutral-700/80 shadow-sm group-hover/row:ring-rose-300 dark:group-hover/row:ring-rose-700 group-hover/row:shadow-md transition-all duration-200">
<img
alt="{{ $episode->title }} - {{ $episode->episode }}"
loading="lazy"
class="object-cover w-full h-full"
src="{{ $episode->gallery->first()->thumbnail_url }}"
class="object-cover w-full h-full group-hover/row:scale-105 transition-transform duration-300"
src="{{ $episode->cover_url }}"
>
<span class="absolute right-0 top-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs font-semibold rounded-tr rounded-bl-xl px-2 py-1">{{ $episode->getResolution() }}</span>
<div class="absolute left-0 bottom-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs rounded-tr-xl px-2 py-1 font-medium">
<i class="fa-regular fa-eye mr-1"></i> {{ $episode->viewCountFormatted() }}
<i class="fa-regular fa-heart ml-2"></i> {{ $episode->likeCount() }}
</div>
<span class="absolute -top-1.5 -right-1.5 bg-rose-600 text-white text-[0.6rem] font-bold leading-none px-1.5 py-0.5 rounded-md shadow-sm">
E{{ $episode->episode }}
</span>
</div>
{{-- Center: Title, Publisher, Tags --}}
<div class="flex-1 min-w-0">
{{-- Row 1: Title --}}
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate group-hover/row:text-rose-700 dark:group-hover/row:text-rose-400 transition-colors duration-150">
{{ $episode->title }}
</h3>
{{-- Row 2: Publisher --}}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
<i class="fa-regular fa-building mr-1 text-[0.65rem] opacity-70"></i>
{{ $episode->studio?->name ?? __('Unknown') }}
</p>
{{-- Row 3: Tags --}}
<div class="mt-1.5 flex items-center gap-1 flex-wrap">
@php
$tags = $episode->tagNames();
$visibleTags = array_slice($tags, 0, 3);
$remainingCount = count($tags) - 3;
@endphp
@foreach($visibleTags as $tag)
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-rose-100/80 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300 ring-1 ring-inset ring-rose-200/60 dark:ring-rose-800/60">
{{ $tag }}
</span>
@endforeach
@if($remainingCount > 0)
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-gray-100 text-gray-500 dark:bg-neutral-800 dark:text-gray-400">
+{{ $remainingCount }}
</span>
@endif
</div>
</div>
<div class="p-3">
<h3 class="text-sm font-semibold truncate text-gray-900 dark:text-white">{{ $episode->title }} - {{ $episode->episode }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> {{ \Illuminate\Support\Str::limit($episode->description ?? '', 80) }}</p>
{{-- Right: View Count, Like Count --}}
<div class="flex-shrink-0 flex flex-col items-end gap-1.5">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Views') }}">
<i class="fa-regular fa-eye text-[0.65rem] opacity-70"></i>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $episode->viewCountFormatted() }}</span>
</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Likes') }}">
<i class="fa-regular fa-heart text-[0.65rem] opacity-70 text-rose-500 dark:text-rose-400"></i>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ number_format($episode->likeCount()) }}</span>
</div>
</div>
</a>
</li>
@endforeach
{{-- Advanced Search card --}}
<a href="{{ route('hentai.search', ['search' => $query]) }}" class="flex items-center justify-center rounded-xl border border-dashed border-gray-200 dark:border-neutral-700 p-6 hover:bg-gray-50 dark:hover:bg-neutral-900 transition">
<div class="text-center">
<div class="text-2xl font-bold text-rose-600 mb-1">🔎</div>
<div class="font-semibold text-sm dark:text-white">Advanced Search</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">View more results</div>
{{-- Advanced Search footer --}}
<li>
<a href="{{ route('hentai.search', ['search' => $query]) }}"
class="block px-5 py-3.5 text-center group/advanced hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
>
<div class="flex items-center justify-center gap-2">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 group-hover/advanced:text-rose-700 dark:group-hover/advanced:text-rose-400 transition-colors">
{{ __('Advanced Search') }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('View all results') }}</span>
<svg class="w-4 h-4 text-gray-400 group-hover/advanced:text-rose-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</a>
</div>
</li>
</ul>
@else
{{-- Empty state --}}
<div class="py-12 text-center text-sm text-gray-600 dark:text-gray-300">
<div class="mb-3">No results found for {{ $query ?: $navSearch }}</div>
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-block px-4 py-2 rounded-lg bg-rose-700 text-white text-sm hover:bg-rose-800">Try advanced search</a>
<div class="mb-3">{{ __('No results found for') }} <span class="font-semibold text-rose-600">"{{ $query ?: $navSearch }}"</span></div>
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-rose-700 text-white text-sm font-medium hover:bg-rose-800 transition shadow-sm hover:shadow-md">
<span>{{ __('Try advanced search') }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
@endif
</div>
@@ -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
class="relative pt-5 mx-auto sm:px-6 lg:px-8 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
<!-- Header -->
<div class="flex text-sm font-light bg-neutral-950/50 backdrop-blur-lg rounded-lg p-10 gap-2">
<div>
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg"
<div
class="flex flex-col items-center gap-6 rounded-2xl border border-neutral-200/70 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:gap-8 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950">
<img class="h-24 w-24 shrink-0 rounded-full shadow-lg ring-2 ring-rose-500/20"
src="{{ $playlist->user->getAvatar() }}">
</div>
<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)
<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
type="text"
wire:model="editingPlaylistName"
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">
Save
</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
</button>
</div>
@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
@else
<h1 class="font-bold text-3xl">
{{ $playlist->name }}
<h1 class="flex items-center justify-center gap-1 text-3xl font-bold text-neutral-900 sm:justify-start dark:text-white">
<span class="truncate">{{ $playlist->name }}</span>
@auth
@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>
</button>
@endif
@endauth
</h1>
@endif
<p class="font-light text-lg text-neutral-200">Episodes: {{ count($playlistEpisodes) }}</p>
<p class="font-light text-lg text-neutral-200">
<p class="mt-1 text-lg font-light text-neutral-500 dark:text-neutral-300">{{ __('playlist.episodes') }}: {{ $playlist->episodes_count }}</p>
<p class="mt-0.5 text-lg font-light text-neutral-500 dark:text-neutral-300">
Creator: {{ $playlist->user->name }}
@auth
@if (Auth::id() === $playlist->user->id)
<span class="ml-3">
<button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-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>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</button>
</span>
@else
<span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-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>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</span>
@@ -59,88 +60,86 @@
@endauth
</p>
</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)
@php $episode = $playlistEpisode->episode; @endphp
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}"
class="flex justify-between items-center rounded-lg hover:bg-black border border-neutral-950 bg-neutral-950/50 backdrop-blur-lg transition !mt-1 gap-2">
<div class="flex pl-5 pr-5 w-10">
{{ $playlistEpisode->position }}
</div>
<div class="flex-[2] hidden md:block">
<div class="relative p-1 w-full md:p-2">
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
src="{{ $episode->gallery->first()->thumbnail_url }}">
@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
<div class="shrink-0">
@if ($firstEpisode)
<a href="{{ route('hentai.index', ['title' => $firstEpisode->slug, 'playlist' => $playlist->id]) }}"
class="inline-flex items-center gap-2 text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
</a>
@else
<a class="inline-flex cursor-not-allowed items-center gap-2 text-neutral-500 bg-neutral-200 font-medium rounded-lg text-sm px-4 py-2 dark:text-neutral-300 dark:bg-neutral-700">
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
</a>
</div>
</div>
<div class="flex-[5]">
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
class="font-bold">{{ $episode->title }} - {{ $episode->episode }}</a>
<br>
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">{{ $episode->title_jpn }}</a>
</div>
<div class="pr-5">
@auth
@if (Auth::user()->id === $playlist->user->id)
<button class="pr-2" wire:click="moveUp({{ $playlistEpisode->id }})"><i
class="fa-solid fa-arrow-up"></i></button>
<button class="pr-2" wire:click="moveDown({{ $playlistEpisode->id }})"><i
class="fa-solid fa-arrow-down"></i></button>
<button wire:click="remove({{ $playlistEpisode->id }})" class="text-red-500"><i
class="fa-solid fa-trash"></i></button>
@endif
@endauth
</div>
</div>
<!-- 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
<div class="pt-6 text-2xl text-center">
No results (╥﹏╥)
<div
class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-clapperboard text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{{ $search !== '' ? __('playlist.no-matches') : __('playlist.empty-playlist') }}
</h3>
</div>
@endforelse
</div>
<!-- Pagination -->
@if ($episodes->hasPages())
<div class="mt-6">{{ $episodes->links('pagination::tailwind') }}</div>
@endif
</div>
</div>
@@ -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>
<!-- Search -->
<div class="p-4 mx-3 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="live-search"
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
<div class="relative right-2 left-0 sm:left-2 transition-all">
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
<!-- Hero -->
<section
class="relative overflow-hidden rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-10 shadow-xl backdrop-blur-lg sm:px-10 sm:py-12 dark:border-neutral-800 dark:bg-neutral-950/40">
<div class="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-rose-600/20 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-neutral-500/20 blur-3xl dark:bg-rose-900/10"></div>
<div class="relative z-10">
<h1 class="text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
{{ __('nav.public-playlists') }}
</h1>
<p class="mt-2 text-neutral-600 dark:text-neutral-400">
{{ __('playlist.hero-subtitle') }}
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<span
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
<i class="fa-solid fa-rectangle-list text-rose-600"></i>
{{ number_format($totalPlaylists) }} {{ __('playlist.playlists-count') }}
</span>
<span
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
<i class="fa-solid fa-film text-rose-600"></i>
{{ number_format($totalEpisodes) }} {{ __('home.episodes') }}
</span>
</div>
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search"
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
placeholder="Search Playlist...">
<div class="absolute right-0 top-[11px]" wire:loading>
<svg aria-hidden="true"
class="inline w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-pink-600"
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="relative">
<label for="playlist-search" class="sr-only">Search</label>
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<i class="fa-solid fa-magnifying-glass text-neutral-400"></i>
</div>
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search" placeholder="Search Playlist..."
class="w-full rounded-full border border-neutral-200/70 bg-white/60 py-3 pl-11 pr-12 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm backdrop-blur transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/30 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-white dark:placeholder-neutral-500">
<div class="absolute inset-y-0 right-4 flex items-center" wire:loading>
<svg aria-hidden="true" class="h-5 w-5 animate-spin text-neutral-400" viewBox="0 0 100 101" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" />
@@ -31,53 +46,165 @@
</svg>
</div>
</div>
</div>
<!-- Ordering -->
<div class="relative right-2 left-0 sm:left-2 transition-all">
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
<i class="fa-solid fa-sort text-gray-500 dark:text-gray-400"></i>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<i class="fa-solid fa-sort text-neutral-400"></i>
</div>
<select wire:model.live="order"
class="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="za">Z-A</option>
<option value="episode-count">Episode count</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-4 flex items-center">
<i class="fa-solid fa-chevron-down text-neutral-400"></i>
</div>
</div>
</div>
</div>
</section>
<!-- Grid -->
<div class="mt-8" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
<div wire:loading.grid
class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@for ($i = 0; $i < 8; $i++)
<div
class="overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/50">
<div class="relative aspect-video overflow-hidden">
<div class="h-full w-full animate-pulse bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
</div>
<div class="space-y-3 p-5">
<div class="h-4 w-3/4 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
<div class="h-3 w-1/2 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
<div class="shimmer-overlay h-full w-full"></div>
</div>
</div>
</div>
@endfor
</div>
<div class="grid-cols-1 sm:grid md:grid-cols-3" wire:keydown.right.window="nextPage"
wire:keydown.left.window="previousPage">
@foreach ($playlists as $playlist)
<div wire:key="playlist-{{ $playlist->id }}"
class="mx-3 mt-6 flex flex-col rounded-lg bg-white shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/50 backdrop-blur-lg sm:shrink-0 sm:grow sm:basis-0 z-10">
<a href="{{ route('playlist.show', $playlist->id) }}">
@php
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)
->orderBy('position', 'desc')
->first();
@endphp
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
alt="Hollywood Sign on The Hill" />
</a>
<div class="p-6">
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
{{ $playlist->name }}
</h5>
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a>
<div wire:loading.remove>
@if ($playlists->isEmpty())
<div
class="rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-16 text-center shadow-xl backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/40">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-rose-600/10 text-rose-600">
<i class="fa-solid fa-rectangle-list text-2xl"></i>
</div>
<p class="mt-4 text-lg font-semibold text-neutral-900 dark:text-white">
{{ __('playlist.no-playlist-found') }}
</p>
@if ($search !== '')
<button type="button" wire:click="$set('search', '')"
class="mt-4 inline-flex items-center gap-2 rounded-full bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700">
<i class="fa-solid fa-xmark"></i>
{{ __('playlist.clear-search') }}
</button>
@endif
</div>
@else
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@foreach ($playlists as $playlist)
@php
$covers = $playlist->episodes
->take(4)
->map(fn ($pe) => $pe->episode?->gallery->first()?->thumbnail_url)
->filter()
->values();
@endphp
<div wire:key="playlist-{{ $playlist->id }}"
class="group flex flex-col overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/30 dark:border-neutral-800 dark:bg-neutral-950/50 dark:hover:border-neutral-700">
<a href="{{ route('playlist.show', $playlist->id) }}"
class="relative block aspect-video overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-900 to-neutral-950">
@if ($covers->isEmpty())
<div class="flex h-full w-full items-center justify-center">
<i class="fa-solid fa-rectangle-list text-4xl text-neutral-500"></i>
</div>
@elseif ($covers->count() === 1)
<img src="{{ $covers[0] }}" alt="{{ $playlist->name }}" loading="lazy"
class="h-full w-full object-cover">
@elseif ($covers->count() === 2)
<div class="grid h-full w-full grid-cols-2">
@foreach ($covers as $cover)
<img src="{{ $cover }}" alt="" loading="lazy"
class="h-full w-full object-cover">
@endforeach
</div>
@elseif ($covers->count() === 3)
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
<img src="{{ $covers[0] }}" alt="" loading="lazy"
class="col-span-2 h-full w-full object-cover">
<img src="{{ $covers[1] }}" alt="" loading="lazy"
class="h-full w-full object-cover">
<img src="{{ $covers[2] }}" alt="" loading="lazy"
class="h-full w-full object-cover">
</div>
@else
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
@foreach ($covers->take(4) as $cover)
<img src="{{ $cover }}" alt="" loading="lazy"
class="h-full w-full object-cover">
@endforeach
</div>
@endif
<span
class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
<i class="fa-solid fa-clapperboard"></i>
{{ $playlist->episodes_count }}
</span>
<div
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition duration-300 group-hover:opacity-100">
<span
class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-600 shadow-lg transition-transform duration-300 group-hover:scale-110">
<i class="fa-solid fa-play pl-1 text-lg text-white"></i>
</span>
</div>
</a>
<div class="flex flex-1 flex-col p-5">
<h3 class="text-base font-semibold text-neutral-900 line-clamp-2 dark:text-neutral-50">
<a href="{{ route('playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
</h3>
<div class="mt-2 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
<img src="{{ $playlist->user?->getAvatar() ?? asset('images/default-avatar.webp') }}"
alt="" class="h-5 w-5 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-700">
<span class="truncate">{{ $playlist->user?->name }}</span>
<span>·</span>
<span class="shrink-0">{{ $playlist->created_at->diffForHumans() }}</span>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500 dark:text-neutral-400">
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
</span>
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-rose-600 to-rose-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:from-rose-700 hover:to-rose-800">
<i class="fa-solid fa-play"></i>
{{ __('playlist.play') }}
</a>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
@if ($playlists->hasPages())
<div class="mt-10 mb-10">
{{ $playlists->links('pagination::tailwind') }}
</div>
@endif
</div>
@@ -1,36 +1,38 @@
<div>
<div class="mx-auto max-w-5xl px-4 space-y-6">
<!-- Filters -->
<div class="p-4 bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow flex flex-col sm:flex-row gap-3">
<!-- Search -->
<div class="space-y-5">
{{-- Filters --}}
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
<div class="flex flex-col sm:flex-row gap-3">
{{-- Search --}}
<div class="relative flex-1">
<input
wire:model.live.debounce.500ms="commentSearch"
type="search"
placeholder="Search comments..."
class="w-full pl-10 pr-4 py-3 rounded-lg border-neutral-300 dark:text-neutral-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
>
<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 flex items-center pl-3">
<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">
<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="commentSearch"
type="search"
placeholder="Search comments..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl border-neutral-300 dark:text-neutral-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
>
</div>
<!-- Order -->
{{-- Order --}}
<div class="relative">
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="order"
class="px-4 py-3 rounded-lg border-neutral-300 dark:text-gray-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 min-w-[128px]"
class="appearance-none pl-10 pr-8 py-2.5 rounded-xl border-neutral-300 dark:text-gray-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 min-w-[128px]"
>
<option value="created_at_desc">Newest</option>
<option value="created_at_asc">Oldest</option>
</select>
</div>
</div>
</div>
<!-- Comments -->
<div class="space-y-4">
{{-- Comments --}}
<div class="space-y-3">
@forelse ($comments as $comment)
@php
@@ -46,35 +48,40 @@
wire:key="comment-{{ $comment->id }}"
class="block group">
<div class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
<div class="rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 overflow-hidden">
<div class="flex flex-col sm:flex-row">
<!-- Thumbnail -->
<div class="sm:w-48 shrink-0">
{{-- Thumbnail --}}
<div class="sm:w-44 shrink-0">
<img
src="{{ $episode->gallery->first()->thumbnail_url }}"
alt=""
class="w-full h-40 sm:h-full object-cover"
alt="{{ $episode->title ?? '' }}"
class="w-full h-36 sm:h-full object-cover"
loading="lazy"
>
</div>
<!-- Content -->
<div class="flex-1 p-4 flex flex-col justify-between">
{{-- Content --}}
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
<!-- Comment -->
<div class="text-gray-800 dark:text-gray-200 text-sm line-clamp-3">
{{-- Episode Title --}}
<p class="text-xs font-medium text-rose-600 dark:text-rose-400 truncate mb-1">
{{ $episode->title ?? 'Episode' }}
</p>
{{-- Comment --}}
<div class="text-sm text-gray-700 dark:text-gray-300 line-clamp-2 leading-relaxed">
{!! $comment->presenter()->markdownBody() !!}
</div>
<!-- Meta -->
<div class="flex items-center justify-between mt-3 text-xs text-gray-700 dark:text-gray-400">
<span>
{{-- Meta --}}
<div class="flex items-center justify-between mt-2.5 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<i class="fa-solid fa-clock text-[10px]"></i>
{{ $comment->presenter()->relativeCreatedAt() }}
</span>
<span class="text-rose-600 font-medium group-hover:underline">
<span class="text-rose-600 dark:text-rose-400 font-medium group-hover:underline">
View comment
</span>
</div>
@@ -83,19 +90,19 @@
</div>
</a>
@empty
<div class="flex bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
<div class="text-gray-800 dark:text-gray-200 text-center w-full p-4">
<p class="text-lg">No results</p>
<p class="text-sm opacity-70">(╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-comment-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No comments found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You haven't commented on anything yet.</p>
</div>
@endforelse
</div>
<!-- Pagination -->
{{-- Pagination --}}
<div>
{{ $comments->links('pagination::tailwind') }}
</div>
</div>
</div>
+122 -10
View File
@@ -1,27 +1,139 @@
<div>
<div class="md:ml-8 my-8 md:my-0 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[95%]">
@include('livewire.partials.search-filter')
<div class="space-y-5">
{{-- Slim Profile Filter --}}
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
<div class="flex flex-col sm:flex-row gap-3">
{{-- Search --}}
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-4 w-4 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.400ms="search"
type="search"
placeholder="Search liked episodes..."
class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-11 pr-4 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
/>
</div>
{{-- Order --}}
<div class="relative">
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="order"
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
>
<option value="az">A-Z</option>
<option value="za">Z-A</option>
<option value="recently-uploaded">{{ __('home.recently-uploaded') }}</option>
<option value="recently-released">{{ __('home.recently-released') }}</option>
<option value="oldest-uploads">{{ __('search.oldest-uploads') }}</option>
<option value="oldest-releases">{{ __('search.oldest-releases') }}</option>
<option value="view-count">{{ __('search.view-count') }}</option>
</select>
</div>
{{-- View --}}
<div class="relative">
<i class="fa-solid fa-list pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="view"
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
>
<option value="thumbnail">Thumbnail</option>
<option value="poster">Poster</option>
</select>
</div>
{{-- Filter Buttons --}}
<div class="flex gap-2">
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalGenres"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-sliders text-[10px]"></i>
Genres
@if($tagcount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $tagcount }}</span>
@endif
</button>
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalBlacklist"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-shield text-[10px]"></i>
Blacklist
@if($blacklistcount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $blacklistcount }}</span>
@endif
</button>
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalStudios"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-microphone-lines text-[10px]"></i>
Studios
@if($studiocount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $studiocount }}</span>
@endif
</button>
</div>
{{-- Hide Watched --}}
@auth
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 whitespace-nowrap">
<input
id="checkBoxHideWatched"
type="checkbox"
wire:model.live="hideWatched"
class="h-4 w-4 rounded border-neutral-300 text-rose-600 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800"
/>
<span>Hide watched</span>
</label>
@endauth
</div>
</div>
{{-- Modals --}}
@include('modals.filter-genres')
@include('modals.filter-studios')
@include('modals.filter-blacklist')
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
<div class="relative md:ml-8 pt-5 mx-auto space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[95%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{-- Results --}}
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
<div class="flex items-center justify-center">
<div class="flex justify-center">
<div class="mt-4">
@if ($view == 'thumbnail')
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@else
<div class="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8 gap-2">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@endif
@forelse($episodes as $episode)
@include('livewire.partials.search-result')
@empty
<div class="col-span-full">
<p class="text-2xl w-52 pt-6">No results (╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-heart-crack text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No liked episodes</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start exploring and like what you enjoy!</p>
</div>
</div>
@endforelse
</div>
</div>
</div>
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
</div>
</div
</div>
</div>
+38 -15
View File
@@ -1,28 +1,51 @@
<div>
<div class="relative mx-auto sm:px-6 lg:px-8 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
<ol class="border-l border-neutral-300 dark:border-neutral-500">
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage" class="text-gray-900 dark:text-white">
<div class="relative">
{{-- Timeline --}}
<ol class="relative border-l-2 border-neutral-300/70 dark:border-neutral-600/70 ml-3 sm:ml-4">
@foreach ($watchedGrouped as $day => $episodes)
<li>
<div class="flex items-center pt-3 flex-start">
<div class="-ml-[5px] mr-3 h-[9px] w-[9px] rounded-full bg-neutral-300 dark:bg-neutral-500">
</div>
<p class="text-sm text-neutral-500 dark:text-neutral-300">
<li class="mb-10 ml-6 sm:ml-8 last:mb-0">
{{-- Timeline Dot --}}
<span class="absolute flex items-center justify-center w-6 h-6 rounded-full -left-3 ring-2 ring-white dark:ring-neutral-950 bg-rose-600 dark:bg-rose-500">
<i class="fa-solid fa-circle text-[6px] text-white"></i>
</span>
{{-- Date Header --}}
<div class="mb-4">
<time class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold text-gray-600 dark:text-gray-300 bg-gray-100/70 dark:bg-neutral-800/70">
<i class="fa-solid fa-calendar text-[10px] text-rose-500"></i>
{{ $episodes->first()->created_at->diffForHumans(['parts' => 1]) }}
</p>
</time>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
{{ $episodes->count() }} {{ Str::plural('episode', $episodes->count()) }}
</span>
</div>
<div class="flex justify-center">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4">
{{-- Episodes Grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($episodes as $episode)
<div class="mt-2 mb-6 ml-4">
<x-episode-cover :episode="$episode->episode" view="thumbnail" />
</div>
@endforeach
</div>
</div>
</li>
@endforeach
</ol>
{{-- Pagination --}}
@if($watched->hasPages())
<div class="mt-8">
{{ $watched->links('pagination::tailwind') }}
</div>
@endif
{{-- Empty State --}}
@if($watched->isEmpty())
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-eye-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No watch history</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start watching and your history will appear here.</p>
</div>
@endif
</div>
</div>
@@ -1,22 +1,17 @@
<div class="grid grid-cols-2">
<p class="cursor-default">{{ __('Blur effects') }}</p>
<div class="flex items-center">
<div class="absolute right-6">
<label for="toggleBlur" class="flex items-center cursor-pointer">
<!-- toggle -->
<div class="relative">
<div class="flex w-full items-center justify-between gap-3 px-3 py-2">
<p class="cursor-default text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('Blur effects') }}</p>
<label for="toggleBlur" class="relative flex cursor-pointer items-center">
<!-- input -->
<input id="toggleBlur" type="checkbox" class="sr-only" checked />
<!-- line -->
<div class="w-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>
<!-- dot -->
<div
class="dot absolute w-6 h-6 bg-white dark:bg-neutral-950 rounded-full shadow -left-1 -top-1 transition">
<div class="items-center ml-[4px] w-6 h-6 font-medium flex">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
<div class="dot absolute w-4 h-4 bg-white dark:bg-neutral-950 rounded-full shadow left-0.5 top-0.5 transition">
<div class="flex h-full w-full items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-blur">
stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-blur text-gray-600 dark:text-gray-200">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035z" />
@@ -29,8 +24,5 @@
</svg>
</div>
</div>
</div>
</label>
</div>
</div>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
@endif
</div>
<div class="mt-1 flex-grow w-full">
<div class="text-gray-700 dark:text-gray-200">{!! $comment->presenter()->markdownBody() !!}</div>
<div class="text-gray-700 dark:text-gray-200">{!! $comment->presenter()->markdownBody($limit ?? 250) !!}</div>
</div>
<div class="mt-2 space-x-2">
<span class="text-gray-500 dark:text-gray-300 font-medium">
@@ -1,16 +1,10 @@
<label for="toogleTheme" class="flex items-center cursor-pointer">
<!-- toggle -->
<div class="relative">
<label for="toogleTheme"
class="relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full ring-1 ring-gray-200 transition-colors hover:bg-rose-50 dark:ring-neutral-700 dark:hover:bg-rose-950/40">
<!-- input -->
<input id="toogleTheme" type="checkbox" class="sr-only" />
<!-- line -->
<div class="w-10 h-4 bg-rose-600 dark:bg-gray-400 rounded-full shadow-inner"></div>
<!-- dot -->
<div class="dot absolute w-6 h-6 bg-white dark:bg-neutral-950 rounded-full shadow -left-1 -top-1 transition">
<div class="items-center ml-[5px] w-6 h-6 font-medium flex">
<i class="fa-regular fa-moon text-white hidden dark:inline"></i>
<!-- icon -->
<div class="theme-icon flex items-center justify-center transition-transform duration-200">
<i class="fa-regular fa-moon text-gray-600 dark:text-gray-200 hidden dark:inline"></i>
<i class="fa-regular fa-sun text-yellow-400 dark:hidden"></i>
</div>
</div>
</div>
</label>
+1 -7
View File
@@ -1,11 +1,5 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight z-10">
{{ __('nav.public-playlists') }}
</h2>
</x-slot>
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
<div class="mx-auto pt-6 sm:px-6 lg:px-8 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%]">
@include('partials.background')
@livewire('playlists')
</div>
+10 -9
View File
@@ -1,11 +1,12 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
@include('profile.partials.sidebar')
<div class="flex flex-col gap-2">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-comment text-rose-500"></i>
{{ __('nav.comments') }}
</h2>
{{-- Content from Livewire --}}
<livewire:user-comments :model="$user"/>
</div>
</div>
</div>
</x-app-layout>
</x-profile-layout>
+121 -7
View File
@@ -1,9 +1,123 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 w-5/6 flex-row">
@include('profile.partials.sidebar')
@include('profile.partials.info')
<x-profile-layout>
<div class="space-y-6">
{{-- Welcome Banner --}}
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-rose-600 via-rose-700 to-pink-700 p-6 sm:p-8 text-white shadow-xl shadow-rose-600/20">
<div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMiIvPjwvZz48L2c+PC9zdmc+')] opacity-50"></div>
<div class="relative z-10">
<h1 class="text-2xl sm:text-3xl font-bold">Welcome back, {{ $user->name }}!</h1>
<p class="mt-2 text-rose-100 text-sm sm:text-base max-w-4xl">
Here's an overview of your activity on hstream.moe. Dive back into your favorites or discover something new.
</p>
</div>
{{-- Decorative circles --}}
<div class="absolute -top-10 -right-10 h-40 w-40 rounded-full bg-white/5 blur-2xl"></div>
<div class="absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-white/5 blur-2xl"></div>
</div>
{{-- Quick Links Grid --}}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-3 sm:gap-4">
<a href="{{ route('profile.likes') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/40 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-heart text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Liked Episodes</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->likes()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes you've liked</p>
</a>
<a href="{{ route('user.watched') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-eye text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Watch History</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->watched->count()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes watched</p>
</a>
<a href="{{ route('profile.playlists') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-rectangle-list text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Your Playlists</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->playlists->count()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Custom collections</p>
</a>
</div>
{{-- Recent Activity Section --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-5 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-5 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left text-rose-500"></i>
Recent Activity
</h3>
@php
$recentWatched = $user->watched()->with('episode')->latest()->take(4)->get();
$recentComments = $user->comments()->latest()->take(2)->get();
@endphp
@if($recentWatched->isEmpty() && $recentComments->isEmpty())
<div class="text-center py-10">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-ghost text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">No activity yet. Start watching something!</p>
</div>
@else
<div class="space-y-4">
{{-- Recently Watched --}}
@if($recentWatched->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recently Watched</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach($recentWatched as $watched)
<a href="{{ route('hentai.index', ['title' => $watched->episode->slug]) }}"
class="group relative overflow-hidden rounded-lg bg-gray-100 dark:bg-neutral-800 aspect-video block">
<img src="{{ $watched->episode->gallery->first()->thumbnail_url ?? '/images/default-avatar.webp' }}"
alt="{{ $watched->episode->title }}"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy">
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<p class="absolute bottom-2 left-2 right-2 text-xs text-white font-medium truncate">
{{ $watched->episode->title }}
</p>
</div>
</a>
@endforeach
</div>
</div>
</x-app-layout>
@endif
{{-- Recent Comments --}}
@if($recentComments->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recent Comments</p>
<div class="space-y-2">
@foreach($recentComments as $comment)
<a href="{{ route('hentai.index', ['title' => $comment->commentable->slug ?? '#']) }}#comment-{{ $comment->id }}"
class="block rounded-lg bg-gray-50/70 dark:bg-neutral-900/50 p-3 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors">
<div class="text-sm text-gray-700 dark:text-gray-200 line-clamp-2">
{!! $comment->presenter()->markdownBody() !!}
</div>
<p class="text-xs text-gray-400 dark:text-gray-400/80 mt-1.5">
{{ $comment->presenter()->relativeCreatedAt() }}
</p>
</a>
@endforeach
</div>
</div>
@endif
</div>
@endif
</div>
</div>
</x-profile-layout>
+12 -7
View File
@@ -1,9 +1,14 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-heart text-rose-500"></i>
{{ __('nav.likes') }}
</h2>
</div>
{{-- Content from Livewire --}}
@livewire('user-likes')
</div>
</div>
</x-app-layout>
</x-profile-layout>
+56 -27
View File
@@ -1,55 +1,84 @@
<x-app-layout>
@include('partials.background')
<div
class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
@include('profile.partials.sidebar')
<div class="flex flex-col gap-2">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-bell text-rose-500"></i>
Notifications
</h2>
@if($notifications->isNotEmpty())
<form method="POST" action="{{ route('profile.notifications.delete') }}" class="hidden sm:block">
@csrf
@method('delete')
<button type="submit"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-800/50 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors">
<i class="fa-solid fa-trash-can text-[10px]"></i>
Clear All
</button>
</form>
@endif
</div>
@forelse($notifications as $notification)
<div
class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur border border-gray-200 dark:border-neutral-700 rounded-xl shadow-sm p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-x-4 transition hover:shadow-md">
<div class="group relative rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md transition-all duration-200 overflow-hidden">
<!-- Content -->
<div class="flex flex-col gap-2 w-full h-full mt-2">
<div class="flex items-center justify-between flex-none h-2">
<span class="text-xs font-semibold uppercase tracking-wide text-sky-600 dark:text-rose-500">
{{-- Unread indicator --}}
@if(is_null($notification->read_at))
<div class="absolute left-0 top-0 bottom-0 w-1 bg-rose-500"></div>
@endif
<div class="p-4 sm:p-5 {{ is_null($notification->read_at) ? 'pl-5 sm:pl-6' : '' }}">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1.5">
<span class="inline-flex items-center gap-1 rounded-full bg-rose-100 dark:bg-rose-900/30 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-rose-700 dark:text-rose-400">
<i class="fa-solid fa-tag text-[9px]"></i>
{{ $notification->data['type'] ?? 'Notification' }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ $notification->created_at->diffForHumans(['parts' => 1]) }}
</span>
</div>
<p class="text-sm sm:text-base text-gray-700 dark:text-gray-300 leading-relaxed h-full">
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{{ $notification->data['message'] ?? '' }}
</p>
</div>
<!-- Actions -->
<div class="flex gap-2 sm:gap-2 shrink-0">
{{-- Actions --}}
<div class="flex items-center gap-2 shrink-0">
@if(isset($notification->data['url']))
<a href="{{ $notification->data['url'] }}"
class="text-center rounded-lg bg-sky-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-sky-700 transition">
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-rose-700 shadow-sm shadow-rose-600/20 transition-all duration-150 hover:shadow-md hover:shadow-rose-600/25">
Open
<i class="fa-solid fa-arrow-right text-[10px]"></i>
</a>
@endif
<form method="POST" action="{{ route('profile.notifications.delete') }}">
@csrf
@method('delete')
<input type="hidden" value="{{ $notification->id }}" name="id">
<button type="submit"
class="w-full rounded-lg bg-rose-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-rose-700 transition">
Delete
class="inline-flex items-center rounded-lg p-2 text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/30 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Delete">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</form>
</div>
</div>
</div>
</div>
@empty
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<p class="text-lg">No notifications</p>
<p class="text-sm opacity-70">(╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-bell-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No notifications</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You're all caught up! Nothing new here.</p>
</div>
@endforelse
</div>
</div>
</div>
</x-app-layout>
</x-profile-layout>
@@ -1,18 +1,26 @@
@auth
<div
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<div class="flex flex-col p-2">
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.settings')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i>
Settings</a>
<div class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="flex flex-col p-1.5">
<a href="{{ route('profile.settings') }}"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.settings'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-gear w-5 text-center text-base
@if(request()->routeIs('profile.settings')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
Settings
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit"
class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-right-from-bracket pr-4"></i>
Logout</button>
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-red-50/60 dark:hover:bg-red-950/40 hover:text-red-600 dark:hover:text-red-400 border-l-[3px] border-transparent transition-all duration-150">
<i class="fa-solid fa-right-from-bracket w-5 text-center text-base text-gray-400 dark:text-gray-500"></i>
Logout
</button>
</form>
</div>
</div>
@@ -0,0 +1,86 @@
<div class="md:hidden mt-5 -mx-1">
<nav class="flex gap-1 overflow-x-auto pb-2 scrollbar-hide">
<a href="{{ route('profile.show') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.show'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-user text-[10px]"></i>
{{ __('nav.profile') }}
</a>
<a href="{{ route('profile.notifications') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.notifications'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-bell text-[10px]"></i>
Notifications
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
@if($unreadCount > 0)
<span class="inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-rose-500 px-1 text-[9px] font-bold text-white">
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
</span>
@endif
</a>
<a href="{{ route('profile.likes') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.likes'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-heart text-[10px]"></i>
{{ __('nav.likes') }}
</a>
<a href="{{ route('user.watched') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('user.watched'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-eye text-[10px]"></i>
{{ __('nav.watched') }}
</a>
<a href="{{ route('profile.comments') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.comments'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-comment text-[10px]"></i>
{{ __('nav.comments') }}
</a>
<a href="{{ route('profile.playlists') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.playlists'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-rectangle-list text-[10px]"></i>
{{ __('nav.playlists') }}
</a>
<a href="{{ route('profile.settings') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.settings'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-gear text-[10px]"></i>
Settings
</a>
</nav>
</div>
@@ -1,15 +1,68 @@
<div
class="overflow-hidden relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl flex items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="{{ $user->getAvatar() }}">
<div class="flex flex-col py-5 pl-24">
<strong class="text-slate-900 text-xl font-bold dark:text-slate-200">
{{ $user->name }}
@if ($user->hasRole(\App\Enums\UserRole::SUPPORTER))
<a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i
class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a>
<div class="overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="relative">
{{-- Profile Banner Gradient --}}
<div class="h-20 bg-gradient-to-br from-rose-500 via-rose-600 to-pink-600 dark:from-rose-700 dark:via-rose-800 dark:to-pink-800"></div>
{{-- Avatar overlapping the banner --}}
<div class="flex justify-center -mt-10">
<div class="relative">
<img class="h-20 w-20 rounded-full border-4 border-white dark:border-neutral-900 shadow-lg object-cover bg-white dark:bg-neutral-800"
src="{{ auth()->user()->getAvatar() }}"
alt="{{ auth()->user()->name }}">
@if(auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER))
<span class="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full bg-rose-600 text-white shadow-md ring-2 ring-white dark:ring-neutral-900"
data-te-toggle="tooltip"
title="Badge of appreciation for the horny people supporting us! :3">
<i class="fa-solid fa-heart text-[11px]"></i>
</span>
@endif
</strong>
<span class="text-slate-500 text-sm font-medium dark:text-slate-400">Joined
{{ $user->created_at->format('Y-m') }}</span>
</div>
</div>
{{-- User Info --}}
<div class="px-4 pb-4 pt-2 text-center">
<h2 class="text-base font-bold text-gray-900 dark:text-gray-100 truncate">
{{ auth()->user()->name }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Joined {{ auth()->user()->created_at->format('F Y') }}
</p>
</div>
{{-- Quick Stats Row --}}
<div class="grid grid-cols-4 border-t border-gray-200/60 dark:border-neutral-800/60">
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->watched->count()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Views
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->commentCount()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Cmts
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->likes()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Likes
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->playlists->count()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Lists
</div>
</div>
</div>
</div>
</div>
@@ -1,37 +1,96 @@
<div class="flex flex-col">
<div>
{{-- Profile Card --}}
@include('profile.partials.profile')
<div
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<div class="flex flex-col p-2">
{{-- Desktop Navigation (hidden on mobile) --}}
<div class="hidden md:block">
<nav
class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="flex flex-col p-1.5">
<a href="{{ route('profile.show') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.show')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-user pr-4"></i> {{ __('nav.profile') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.show'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-user w-5 text-center text-base
@if(request()->routeIs('profile.show')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.profile') }}</span>
</a>
<a href="{{ route('profile.notifications') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.notifications')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-bell pr-4"></i> Notifications</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.notifications'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-bell w-5 text-center text-base
@if(request()->routeIs('profile.notifications')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>Notifications</span>
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
@if($unreadCount > 0)
<span class="ml-auto inline-flex items-center justify-center h-5 min-w-[20px] rounded-full bg-rose-600 px-1.5 text-[10px] font-bold text-white">
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
</span>
@endif
</a>
<a href="{{ route('profile.likes') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.likes')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.likes'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-heart w-5 text-center text-base
@if(request()->routeIs('profile.likes')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.likes') }}</span>
</a>
<a href="{{ route('user.watched') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('user.watched')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-eye pr-4"></i>
{{ __('nav.watched') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('user.watched'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-eye w-5 text-center text-base
@if(request()->routeIs('user.watched')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.watched') }}</span>
</a>
<a href="{{ route('profile.comments') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.comments')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-comment pr-4"></i>
{{ __('nav.comments') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.comments'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-comment w-5 text-center text-base
@if(request()->routeIs('profile.comments')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.comments') }}</span>
</a>
<a href="{{ route('profile.playlists') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.playlists')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-rectangle-list pr-4"></i>
{{ __('nav.playlists') }}</a>
</div>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.playlists'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-rectangle-list w-5 text-center text-base
@if(request()->routeIs('profile.playlists')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.playlists') }}</span>
</a>
</div>
</nav>
{{-- Actions --}}
@include('profile.partials.actions')
</div>
{{-- Mobile Navigation (horizontal scroll tabs, visible only on mobile) --}}
@include('profile.partials.mobile-nav')
</div>
@@ -1,34 +1,43 @@
<div>
<div class="grid-cols-1 sm:grid md:grid-cols-3 ">
@if(count($playlists) > 0)
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@foreach($playlists as $playlist)
@php
$count = $playlist->episodes->count();
@endphp
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 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/60 sm:shrink-0 sm:grow sm:basis-0">
@if($count > 0)
<a href="{{ route('profile.playlist.show', $playlist->id) }}">
@else
<a href="#!">
@endif
<div class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 flex flex-col">
{{-- Thumbnail --}}
@if($count > 0)
<a href="{{ route('profile.playlist.show', $playlist->id) }}" class="block overflow-hidden">
@php
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position', 'desc')->first();
@endphp
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}" alt="Hollywood Sign on The Hill" />
@else
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
@endif
<img class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105"
src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
alt="{{ $playlist->name }}"
loading="lazy" />
</a>
<div class="p-6" x-data="{ editing: false, name: '{{ $playlist->name }}', isPrivate: {{ $playlist->is_private ? 'true' : 'false' }} }">
<!-- Edit mode -->
@else
<div class="w-full aspect-video bg-gray-200 dark:bg-neutral-800 flex items-center justify-center">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp"
class="w-full aspect-video object-cover opacity-30 dark:opacity-15"
alt="Empty playlist" />
</div>
@endif
{{-- Content --}}
<div class="p-4 flex-1 flex flex-col"
x-data="{ editing: false, name: '{{ $playlist->name }}', isPrivate: {{ $playlist->is_private ? 'true' : 'false' }} }">
{{-- Edit mode --}}
<template x-if="editing">
<div>
<form method="POST" action="{{ route('profile.playlist.update', $playlist->id) }}" class="space-y-3">
<div class="flex-1 flex flex-col">
<form method="POST" action="{{ route('profile.playlist.update', $playlist->id) }}" class="flex-1 flex flex-col">
@csrf
@method('PATCH')
<div class="space-y-3 flex-1">
<div>
<label class="mb-1 block text-xs font-medium text-neutral-600 dark:text-neutral-400">Name:</label>
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Name</label>
<input
type="text"
name="name"
@@ -39,7 +48,7 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-neutral-600 dark:text-neutral-400">Visibility:</label>
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Visibility</label>
<select
name="is_private"
x-model="isPrivate"
@@ -49,11 +58,12 @@
<option value="1">Private</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-lg bg-rose-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-rose-700">
</div>
<div class="flex gap-2 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<button type="submit" class="cursor-pointer rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-700 flex-1">
Save
</button>
<button type="button" @click="editing = false; name = '{{ $playlist->name }}'; isPrivate = {{ $playlist->is_private ? 'true' : 'false' }}" class="cursor-pointer rounded-lg border border-neutral-300 px-4 py-2 text-xs font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
<button type="button" @click="editing = false; name = '{{ $playlist->name }}'; isPrivate = {{ $playlist->is_private ? 'true' : 'false' }}" class="cursor-pointer rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-600 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800 flex-1">
Cancel
</button>
</div>
@@ -61,52 +71,81 @@
</div>
</template>
<!-- Display mode -->
{{-- Display mode --}}
<template x-if="!editing">
<div>
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
<div class="flex-1 flex flex-col">
<div class="flex-1">
<h5 class="text-base font-semibold text-neutral-800 dark:text-neutral-50 truncate">
{{ $playlist->name }}
</h5>
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
{{ $count }} Episodes - {{ $playlist->is_private == 1 ? 'Private' : 'Public' }}
@if($count > 0)
<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>
@endif
</p>
<div class="flex items-center gap-2 mt-1.5">
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full bg-neutral-100 dark:bg-neutral-700 px-2 py-0.5 text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
<i class="fa-solid fa-film text-[9px]"></i>
{{ $count }} {{ Str::plural('ep', $count) }}
</span>
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full text-[11px] font-medium
{{ $playlist->is_private ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' }}">
<i class="fa-solid fa-{{ $playlist->is_private ? 'lock' : 'globe' }} text-[9px]"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</span>
</div>
</div>
<div class="flex items-center justify-between mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center gap-3">
<a href="{{ route('profile.playlist.delete', $playlist->id) }}" class="inline-flex items-center cursor-pointer text-xs text-red-600" data-confirm-delete="true">Delete</a>
<button @click="editing = true" class="inline-flex items-center cursor-pointer text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Edit</button>
<a href="{{ route('profile.playlist.delete', $playlist->id) }}"
class="inline-flex items-center gap-1 text-[11px] text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
data-confirm-delete="true">
<i class="fa-solid fa-trash-can text-[10px]"></i>
Delete
</a>
<button @click="editing = true"
class="inline-flex items-center gap-1 text-[11px] text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors">
<i class="fa-solid fa-pen-to-square text-[10px]"></i>
Edit
</button>
</div>
@if($count > 0)
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
<i class="fa-solid fa-play text-[9px]"></i>
Play
</a>
@endif
</div>
</div>
</template>
</div>
</div>
@endforeach
<!-- Add Another Playlist -->
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 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/60 sm:shrink-0 sm:grow sm:basis-0">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-40" alt="..." />
<div class="p-6">
<p class="text-black dark:text-white">
Create another Playlist
</p>
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
Create
</a>
{{-- Add New Playlist Card --}}
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="group rounded-xl border-2 border-dashed border-neutral-300 dark:border-neutral-700 bg-white/20 dark:bg-neutral-950/20 backdrop-blur hover:border-rose-400 dark:hover:border-rose-600 hover:bg-rose-50/50 dark:hover:bg-rose-950/20 transition-all duration-200 flex flex-col items-center justify-center p-8 min-h-[200px]">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200 mb-3">
<i class="fa-solid fa-plus text-xl"></i>
</div>
<p class="text-sm font-semibold text-neutral-600 dark:text-neutral-300">Create Playlist</p>
<p class="text-xs text-neutral-400 dark:text-neutral-500 mt-1">Organize your favorites</p>
</button>
</div>
@else
<!-- No Playlist Found -->
<div 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-700 sm:shrink-0 sm:grow sm:basis-0">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
<div class="p-6">
<p class="text-black dark:text-white">
No Playlist found!
</p>
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
Create
</a>
{{-- Empty State --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-rectangle-list text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No playlists yet</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 mb-6">Create your first playlist to organize your favorite episodes.</p>
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
<i class="fa-solid fa-plus text-xs"></i>
Create your first playlist
</button>
</div>
@endif
</div>
</div>
+21 -11
View File
@@ -1,14 +1,24 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-rectangle-list text-rose-500"></i>
{{ __('nav.playlists') }}
</h2>
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-rose-600/20 hover:bg-rose-700 transition-colors">
<i class="fa-solid fa-plus text-xs"></i>
New Playlist
</button>
</div>
{{-- Content --}}
@include('profile.partials.user-playlists')
</div>
</div>
{{-- Modal --}}
@include('modals.create-playlist')
</div>
</div>
</x-app-layout>
</x-profile-layout>
+111 -15
View File
@@ -1,30 +1,126 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 mb-14 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-gear text-rose-500"></i>
Settings
</h2>
{{-- Settings Sections --}}
<div class="space-y-5">
{{-- Profile Information --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<i class="fa-solid fa-user-pen text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Profile Information</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Update your name, email and avatar</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-profile-information-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Passkeys --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400">
<i class="fa-solid fa-key text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Passkeys</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Manage WebAuthn passkeys for passwordless login</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
<livewire:passkeys />
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Password --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<i class="fa-solid fa-lock text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Update Password</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Keep your account secure</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-password-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Search Blacklist --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<i class="fa-solid fa-shield text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Search Blacklist</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Hide content with specific tags from search results</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-blacklist-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Website Design --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<i class="fa-solid fa-object-group text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Website Design</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Customize your browsing experience</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-design-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Danger Zone --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-red-200/60 dark:ring-red-800/30 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-red-200/60 dark:border-red-800/30">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<i class="fa-solid fa-triangle-exclamation text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-red-700 dark:text-red-400">Danger Zone</h3>
<p class="text-xs text-red-500 dark:text-red-400">Irreversible actions - proceed with caution</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.delete-user-form')
</div>
@include('profile.partials.delete-user-modal')
</div>
</div>
{{-- Delete Account Modal --}}
@include('profile.partials.delete-user-modal')
@vite(['resources/js/user-blacklist.js'])
</div>
</div>
</x-app-layout>
</x-profile-layout>
+10 -7
View File
@@ -1,9 +1,12 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-eye text-rose-500"></i>
{{ __('nav.watched') }}
</h2>
{{-- Content from Livewire --}}
@livewire('watched', ['user' => $user])
</div>
</div>
</x-app-layout>
</x-profile-layout>
+6 -2
View File
@@ -16,7 +16,9 @@
@if($isMobile)
<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>
@endif
@@ -27,7 +29,9 @@
</div>
<div class="flex flex-col">
@if(! $isMobile)
@include('stream.partials.playlist')
@isset($playlist)
<livewire:playlist-sidebar :playlist-id="$playlist->id" :current-episode-id="$episode->id" />
@endisset
@endif
@include('stream.partials.more-episodes')
@@ -1,94 +0,0 @@
@isset($playlist)
<div class="pt-2 sm:px-2 lg:px-4 2xl:w-[450px]">
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800">
<div class="p-4">
<p class="leading-normal font-bold text-lg text-rose-600 pb-2">
@if ($playlist->is_private)
<a href="{{ route('profile.playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
@else
<a href="{{ route('playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
@endif
</p>
@php
$episodeCount = $playlistEpisodes->count();
$currentIndex = 0;
$nextEpisode = "";
if ($episodeCount > 1) {
$currentIndex = $playlistEpisodes->search(fn($playlistEpisode) => $playlistEpisode->episode->id == $episode->id);
$nextEpisode = $currentIndex !== false && $currentIndex + 1 < $episodeCount
? $playlistEpisodes[$currentIndex + 1]->episode->slug
: "";
}
@endphp
<p class="text-neutral-800 dark:text-neutral-300">
{{ $playlist->user->name }} {{ $currentIndex + 1 }}/{{ $episodeCount }} Episodes
</p>
</div>
<!-- Table -->
<div id="scrollable" class="flex-none min-w-full px-4 sm:px-6 md:px-0 overflow-auto scrollbar:!w-1.5 scrollbar:!h-1.5 scrollbar:bg-transparent scrollbar-track:!bg-slate-100 scrollbar-thumb:!rounded scrollbar-thumb:!bg-slate-300 scrollbar-track:!rounded dark:scrollbar-track:!bg-slate-500/[0.16] dark:scrollbar-thumb:!bg-slate-500/50 max-h-96 lg:supports-scrollbars:pr-2 lg:max-h-96">
<div class="overflow-y-auto">
<div class="space-y-2 p-0 pb-2 sm:p-2">
@php
$counter = 1;
$isAuthedUsersPlaylist = false;
if (auth()->check() && $playlist->user->id == auth()->user()->id) {
$isAuthedUsersPlaylist = true;
}
@endphp
@foreach($playlistEpisodes as $playlistEpisode)
@if ($playlistEpisode->episode->id == $episode->id)
<div class="flex items-center gap-4 p-2 bg-rose-800/30 rounded-lg shadow swipe-container transition-colors" id="active">
@else
<div
class="flex items-center gap-4 p-2 dark:bg-neutral-900/50 bg-white rounded-lg shadow transition-colors @if($isMobile && $isAuthedUsersPlaylist) swipe-container @endif"
id="{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}">
@endif
<div class="text-black dark:text-white">
@if ($playlistEpisode->episode->id == $episode->id)
<i class="fa-solid fa-play w-[15px]"></i>
@else
<p class="w-[15px]">{{ $counter }}</p>
@endif
</div>
<a href="{{ route('hentai.index', ['title' => $playlistEpisode->episode->slug, 'playlist' => $playlist->id ]) }}" class="contents">
<img loading="lazy" src="{{ $playlistEpisode->episode->gallery->first()->thumbnail_url }}" alt="{{ $playlistEpisode->episode->title }} - {{ $playlistEpisode->episode->episode }}" class="w-20 h-14 object-cover rounded">
</a>
<div class="grow">
<a href="{{ route('hentai.index', ['title' => $playlistEpisode->episode->slug, 'playlist' => $playlist->id ]) }}">
<p class="text-black dark:text-white font-medium text-sm break-words">{{ $playlistEpisode->episode->title }} - {{ $playlistEpisode->episode->episode }}</p>
</a>
<p class="text-gray-700 dark:text-gray-300 text-xs truncate">{{ $playlistEpisode->episode->viewCount() }} Views - {{ $playlistEpisode->episode->studio->name }}</p>
</div>
@if ($playlistEpisode->episode->id != $episode->id && $isAuthedUsersPlaylist)
@if($isMobile)
<div class="justify-self-end flex items-center">
<i class="transition-all fa-solid fa-grip-lines-vertical cursor-grab text-black dark:text-white" id="del-{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}"></i>
</div>
@else
<div class="justify-self-end flex items-center">
<a class="transition-all fa-solid fa-trash cursor-pointer text-red-700/80" id="delD-{{ $playlist->id }}-{{ $playlistEpisode->episode->id }}"></a>
</div>
@endif
@endif
</div>
@php $counter++; @endphp
@endforeach
</div>
</div>
</div>
</div>
</div>
<input id="playlist_id" type="hidden" value="{{ $playlist->id }}">
<input id="playlist_next_episode_slug" type="hidden" value="{{ $nextEpisode }}">
<script>
// Select the scrollable div and the target child element
const scrollableDiv = document.getElementById('scrollable');
const targetElement = document.getElementById('active');
// Scroll to the target element
scrollableDiv.scrollTop = targetElement.offsetTop - scrollableDiv.offsetTop - 50;
</script>
@endisset
-5
View File
@@ -41,11 +41,6 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
// Release
Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index');
Route::post('/admin/release/upload', [ReleaseController::class, 'store'])->name('admin.upload');
// Episode
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
// Get Tags used for Upload Form
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
-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\Studios;
use App\Services\GalleryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\RefreshDatabase;
use Tests\TestCase;
class GalleryServiceTest extends TestCase
@@ -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));
}
}

Some files were not shown because too many files have changed in this diff Show More