Compare commits

31 Commits

Author SHA1 Message Date
w33b f7c37df755 Show subtitles toggle & menu 2026-07-29 22:12:51 +02:00
w33b 6a9d3b25bf Add styling to plyr and fix switcher not hiding 2026-07-28 21:54:50 +02:00
w33b 75b98de746 Add plyr again as fallback 2026-07-28 21:41:30 +02:00
w33b eaf48276f0 Update player setting design 2026-07-28 20:33:20 +02:00
w33b 2e3918def4 Custom video player 2026-07-26 22:26:37 +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
w33b ee1c17b903 Remove unused function 2026-07-19 20:31:43 +02:00
w33b a5bf2ac245 Add video engagement tracking and heatmap 2026-07-19 20:13:55 +02:00
w33b 4f81164b44 Only track was watched if more than 10s is played #6 2026-07-19 16:10:24 +02:00
w33b 859a35847a Improve admin user & comment moderation page 2026-07-19 15:10:20 +02:00
w33b f35d1a119e Add ability to edit the playlist name and visibility #5 2026-07-19 13:22:28 +02:00
w33b a04d58c60f Remove external subscription system 2026-07-18 14:03:03 +02:00
w33b bcd15d8569 Discord socialite: catch InvalidStateException 2026-06-15 21:14:35 +02:00
w33b 133a069890 Fix attempt to read property on null on playlist page 2026-06-15 20:51:07 +02:00
w33b b32465a7d0 Fix social preview & SEO optimization 2026-06-14 14:07:10 +02:00
w33b f8022b3f18 Use CSS classes to hide n-th elements instead of JS 2026-05-27 17:12:13 +02:00
w33b 57d1ec34c3 Show two covers side by side on mobile 2026-05-26 16:14:29 +02:00
w33b 81639aaabf Use laravel task scheduler 2026-05-26 15:17:57 +02:00
w33b 5dc1bff60c Add an indicator for v2 releases in the download popup #7 2026-05-26 14:36:21 +02:00
w33b 2f3f0edc30 Add ability to edit hentai title & Update design of edit modal 2026-05-26 14:18:48 +02:00
w33b a71b2976af Add ability for mods to edit episode & Refactor code 2026-05-26 13:45:13 +02:00
w33b 2c016274ab Update packages 2026-05-26 12:06:54 +02:00
w33b 5ba0a55316 Update series page design & Refactor 2026-05-26 12:01:36 +02:00
w33b a6fe34a0d1 Reduce queries on home page by eager loading into cache 2026-05-24 22:15:16 +02:00
w33b bb53e06c69 Smaller style fix 2026-05-24 21:39:46 +02:00
w33b 5cae5dc658 Fix more responsiveness issues 2026-05-24 19:39:20 +02:00
w33b 356d07365f Update episode card design 2026-05-24 19:38:50 +02:00
w33b 3574d20fae Update hentai info design & Remove livewire view count 2026-05-24 15:52:22 +02:00
w33b 9fc9e8ed10 Update comments design 2026-05-24 14:49:46 +02:00
105 changed files with 9462 additions and 3100 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ class GenerateSitemap extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sitemap:generate'; protected $signature = 'app:generate-sitemap';
/** /**
* The console command description. * The console command description.
@@ -1,102 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Contracts\Encryption\DecryptException;
class SyncSubscriptionKeys extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:sync-subscription-keys';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync local users against active subscription keys';
/**
* Execute the console command.
*/
public function handle()
{
$endpoint = config('services.subscription_service_host');
if (!$endpoint) {
$this->error('Missing endpoint.');
return self::FAILURE;
}
$response = Http::asJson()
->acceptJson()
->timeout(20)
->retry(3, 1000)
->post($endpoint.'/api/membership/keys', [
'payload' => base64_encode(Crypt::encryptString('get-active-keys')),
]);
if (! $response->successful()) {
$this->error('Subscription API request failed: HTTP ' . $response->status());
return self::FAILURE;
}
try {
$decrypted = Crypt::decryptString(base64_decode($response->json('payload')));
$activeKeys = json_decode($decrypted, true, flags: JSON_THROW_ON_ERROR);
} catch (DecryptException $e) {
$this->error('Could not decrypt API response.');
return self::FAILURE;
} catch (\JsonException $e) {
$this->error('API returned invalid JSON.');
return self::FAILURE;
}
if (! is_array($activeKeys)) {
$this->error('API response payload was not an array.');
return self::FAILURE;
}
$activeKeys = collect($activeKeys)
->filter(fn ($key) => is_string($key) && $key !== '')
->values()
->all();
$this->markInactiveUsers($activeKeys);
$this->markActiveUsers($activeKeys);
return self::SUCCESS;
}
private function markInactiveUsers(array $activeKeys)
{
User::query()
->whereNotNull('subscription_key')
->whereNotIn('subscription_key', $activeKeys)
->chunk(100, function ($users) {
foreach($users as $user) {
$user->removeRole(UserRole::SUPPORTER);
}
});
}
private function markActiveUsers(array $activeKeys)
{
User::query()
->whereNotNull('subscription_key')
->whereIn('subscription_key', $activeKeys)
->chunk(100, function ($users) {
foreach($users as $user) {
$user->addRole(UserRole::SUPPORTER);
}
});
}
}
+98 -2
View File
@@ -3,11 +3,13 @@
namespace App\Helpers; namespace App\Helpers;
use App\Models\Comment; use App\Models\Comment;
use App\Models\Downloads;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\PopularDaily; use App\Models\PopularDaily;
use App\Models\PopularMonthly; use App\Models\PopularMonthly;
use App\Models\PopularWeekly; use App\Models\PopularWeekly;
use App\Models\User;
use Conner\Tagging\Model\Tag; use Conner\Tagging\Model\Tag;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; 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) public static function getPopularAllTime(bool $guest)
{ {
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
@@ -128,7 +224,7 @@ class CacheHelper
public static function getLatestComments() public static function getLatestComments()
{ {
return Cache::remember('latest_comments', now()->addMinutes(60), function () { return Cache::remember('latest_comments', now()->addMinutes(60), function () {
return Comment::latest()->take(10)->get(); return Comment::with('user')->latest()->take(10)->get();
}); });
} }
} }
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\DiscordReleaseNotification; use App\Jobs\DiscordReleaseNotification;
use App\Models\Episode; use App\Models\Episode;
@@ -63,6 +64,17 @@ class EpisodeController extends Controller
public function update(Request $request): RedirectResponse public function update(Request $request): RedirectResponse
{ {
$episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail(); $episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
if ($request->user()->hasRole(UserRole::MODERATOR)) {
$this->episodeService->updateEpisodeModerator($request, $episode->id);
cache()->flush();
return to_route('hentai.index', [
'title' => $episode->slug,
]);
}
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value); $studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
$oldinterpolated = $episode->interpolated; $oldinterpolated = $episode->interpolated;
@@ -4,7 +4,14 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Episode; use App\Models\Episode;
use App\Models\VideoEngagement;
use App\Models\Watched;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
class StreamApiController extends Controller class StreamApiController extends Controller
{ {
@@ -34,4 +41,103 @@ class StreamApiController extends Controller
'extra_subtitles' => $subtitles, 'extra_subtitles' => $subtitles,
], 200); ], 200);
} }
/**
* Track that the authenticated user has watched the episode.
* Called client-side after 10 seconds of playback.
*/
public function trackWatched(Request $request): JsonResponse
{
$validated = $request->validate([
'episode_id' => 'required|integer|exists:episodes,id',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['watched' => false], 401);
}
$episodeId = $request->input('episode_id');
// 1-hour cooldown to prevent duplicate entries
$time = Carbon::now()->subHour(1);
$alreadyWatched = Watched::where('user_id', $user->id)
->where('episode_id', $episodeId)
->where('created_at', '>=', $time)
->exists();
if (! $alreadyWatched) {
Watched::create(['user_id' => $user->id, 'episode_id' => $episodeId]);
cache()->forget('user'.$user->id.'watched'.$episodeId);
}
return response()->json(['watched' => true], 200);
}
/**
* Track engagement segments that the authenticated user has watched.
* Called periodically client-side while the video is playing.
* Segments are 10-second chunks; segment 0 (0-10s) is excluded.
*/
public function trackEngagement(Request $request): JsonResponse
{
$validated = $request->validate([
'episode_id' => 'required|integer|exists:episodes,id',
'segments' => 'required|array',
'segments.*' => 'integer|min:1',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['tracked' => 0], 401);
}
$rateLimitKey = 'engagement:'.$user->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
return response()->json([
'tracked' => 0,
'message' => 'Rate limit exceeded. Try again in '.$seconds.' seconds.',
], 429);
}
RateLimiter::hit($rateLimitKey, 60);
$episodeId = $request->input('episode_id');
$segments = $request->input('segments');
$tracked = 0;
foreach ($segments as $segment) {
// upsert: insert if not exists, otherwise ignore (unique constraint prevents dupes)
VideoEngagement::firstOrCreate([
'episode_id' => $episodeId,
'user_id' => $user->id,
'segment' => $segment,
]);
$tracked++;
}
return response()->json(['tracked' => $tracked], 200);
}
/**
* Get engagement heatmap data for an episode.
* Returns watch counts per segment, aggregated across all users.
*/
public function getEngagement(int $episodeId): JsonResponse
{
$episode = Episode::findOrFail($episodeId);
$data = cache()->remember(
"engagement:{$episodeId}",
600, // 10-minute cache
fn () => VideoEngagement::where('episode_id', $episodeId)
->select('segment', DB::raw('count(*) as watch_count'))
->groupBy('segment')
->pluck('watch_count', 'segment')
->toArray()
);
return response()->json($data, 200);
}
} }
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
class DiscordAuthController extends Controller class DiscordAuthController extends Controller
{ {
@@ -26,7 +27,12 @@ class DiscordAuthController extends Controller
*/ */
public function callback(): RedirectResponse public function callback(): RedirectResponse
{ {
$discordUser = Socialite::driver('discord')->user(); try {
$discordUser = Socialite::driver('discord')->user();
} catch (InvalidStateException $e) {
return redirect()->route('login')
->with('error', 'Your login session expired. Please try signing in again.');
}
$user = User::where('discord_id', $discordUser->id)->first(); $user = User::where('discord_id', $discordUser->id)->first();
+12
View File
@@ -103,6 +103,18 @@ class HomeController extends Controller
'viewCount' => CacheHelper::getTotalViewCount(), 'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(), 'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'hentaiCount' => CacheHelper::getTotalHentaiCount(), '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 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([ $request->validate([
'id' => 'required|exists:notifications,id', 'id' => 'required|exists:notifications,id',
]); ]);
@@ -95,6 +95,34 @@ class PlaylistController extends Controller
return to_route('profile.playlists'); return to_route('profile.playlists');
} }
/**
* Update user playlist.
*/
public function updatePlaylist(Request $request, $playlist_id): RedirectResponse
{
if (! is_numeric($playlist_id)) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|max:30',
'is_private' => 'required|boolean',
]);
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)
->where('id', $playlist_id)
->firstOrFail();
$playlist->update([
'name' => $request->input('name'),
'is_private' => $request->input('is_private'),
]);
return back()->with('status', 'playlist-updated');
}
/** /**
* Delete user playlist. * Delete user playlist.
*/ */
@@ -95,16 +95,6 @@ class ProfileController extends Controller
]); ]);
} }
/**
* Display the user's subscription page.
*/
public function subscription(Request $request): View
{
return view('profile.subscription', [
'user' => $request->user(),
]);
}
/** /**
* Update user settings. * Update user settings.
*/ */
-14
View File
@@ -8,10 +8,8 @@ use App\Models\Gallery;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistEpisode; use App\Models\PlaylistEpisode;
use App\Models\Watched;
use hisorange\BrowserDetect\Facade as Browser; use hisorange\BrowserDetect\Facade as Browser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\View; use Illuminate\View\View;
@@ -52,18 +50,6 @@ class StreamController extends Controller
// Increment Popular Count // Increment Popular Count
$episode->incrementPopularCount(); $episode->incrementPopularCount();
if (! Auth::guest()) {
$user = Auth::user();
// Add to user watched list
$time = Carbon::now()->subHour(1);
$alreadyWatched = Watched::where('user_id', $user->id)->where('episode_id', $episode->id)->where('created_at', '>=', $time)->exists();
if (! $alreadyWatched) {
Watched::create(['user_id' => $user->id, 'episode_id' => $episode->id]);
cache()->forget('user'.$user->id.'watched'.$episode->id);
}
}
// Mobile Detection // Mobile Detection
$isMobile = Browser::isMobile(); $isMobile = Browser::isMobile();
+124 -11
View File
@@ -2,7 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Enums\UserRole;
use App\Models\Comment; use App\Models\Comment;
use App\Models\User;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -10,37 +13,147 @@ class AdminCommentSearch extends Component
{ {
use WithPagination; use WithPagination;
#[Url(history: true)]
public $search = ''; public $search = '';
#[Url(history: true)]
public $userSearch = ''; public $userSearch = '';
public function updatingSearch(): void #[Url(history: true)]
public $sortField = 'created_at';
#[Url(history: true)]
public $sortDirection = 'desc';
#[Url(history: true)]
public $perPage = 20;
public $selected = [];
public $selectPage = false;
protected $queryString = [
'search' => ['except' => ''],
'userSearch' => ['except' => ''],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
'perPage' => ['except' => 20],
];
protected $allowedSortFields = ['id', 'body', 'created_at', 'user_id'];
protected $allowedPerPages = [10, 20, 50, 100];
public function updatedPerPage(): void
{ {
$this->resetPage(); $this->resetPage();
} }
public function updatingUserSearch(): void public function updatedPage(): void
{ {
$this->selectPage = false;
$this->selected = [];
}
public function updatedSelectPage($value): void
{
if ($value) {
$this->selected = $this->comments->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} else {
$this->selected = [];
}
}
public function sortBy(string $field): void
{
if (! in_array($field, $this->allowedSortFields)) {
return;
}
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters(): void
{
$this->search = '';
$this->userSearch = '';
$this->sortField = 'created_at';
$this->sortDirection = 'desc';
$this->perPage = 20;
$this->resetPage(); $this->resetPage();
} }
public function deleteComment($commentId) public function deleteComment(int $commentId): void
{ {
$comment = Comment::where('id', (int) $commentId)->firstOrFail(); $comment = Comment::findOrFail($commentId);
$comment->delete(); $comment->delete();
cache()->flush();
$this->dispatch('notify', type: 'success', message: 'Comment deleted successfully.');
}
public function bulkDelete(): void
{
$count = Comment::whereIn('id', array_map('intval', $this->selected))->delete();
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} comment(s) deleted.");
}
public function banCommentAuthor(int $commentId): void
{
$comment = Comment::findOrFail($commentId);
$user = $comment->user;
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
} else {
$this->dispatch('notify', type: 'error', message: 'User is already banned or not found.');
}
}
public function bulkBanAuthors(): void
{
$count = 0;
$userIds = Comment::whereIn('id', array_map('intval', $this->selected))
->pluck('user_id')
->unique();
foreach ($userIds as $userId) {
$user = User::find($userId);
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
$count++;
}
}
cache()->flush(); cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} comment author(s) banned.");
}
public function getCommentsProperty()
{
return Comment::query()
->with('user')
->when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%{$this->search}%"))
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($q) => $q->where('name', 'LIKE', "%{$this->userSearch}%")))
->orderBy($this->sortField, $this->sortDirection)
->paginate((int) $this->perPage);
} }
public function render() public function render()
{ {
$comments = Comment::when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%$this->search%"))
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($query) => $query->where('name', 'LIKE', "%{$this->userSearch}%")))
->orderBy('created_at', 'DESC')
->paginate(12);
return view('livewire.admin-comment-search', [ return view('livewire.admin-comment-search', [
'comments' => $comments, 'comments' => $this->comments,
]); ]);
} }
} }
+222 -15
View File
@@ -20,32 +20,239 @@ class AdminUserSearch extends Component
public $discordId = ''; public $discordId = '';
#[Url(history: true)] #[Url(history: true)]
public $patreon = []; public $email = '';
#[Url(history: true)] #[Url(history: true)]
public $banned = []; public $roleFilter = [];
public function deleteUserComments(int $userID) #[Url(history: true)]
public $sortField = 'created_at';
#[Url(history: true)]
public $sortDirection = 'desc';
#[Url(history: true)]
public $perPage = 20;
public $selected = [];
public $selectAll = false;
public $selectPage = false;
// Modal state
public $showUserModal = false;
public $modalUser = null;
public $modalUserComments = [];
protected $queryString = [
'search' => ['except' => ''],
'discordId' => ['except' => ''],
'email' => ['except' => ''],
'roleFilter' => ['except' => []],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
'perPage' => ['except' => 20],
];
protected $allowedSortFields = ['id', 'name', 'email', 'discord_id', 'created_at', 'updated_at'];
protected $allowedPerPages = [10, 20, 50, 100];
public function updatedPerPage(): void
{ {
$user = User::where('id', $userID) $this->resetPage();
->firstOrFail(); }
Comment::where('user_id', $user->id) public function updatedPage(): void
->delete(); {
$this->selectPage = false;
$this->selected = [];
}
public function updatedSelectPage($value): void
{
if ($value) {
$this->selected = $this->users->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} else {
$this->selected = [];
}
}
public function sortBy(string $field): void
{
if (! in_array($field, $this->allowedSortFields)) {
return;
}
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters(): void
{
$this->search = '';
$this->discordId = '';
$this->email = '';
$this->roleFilter = [];
$this->sortField = 'created_at';
$this->sortDirection = 'desc';
$this->perPage = 20;
$this->resetPage();
}
public function viewUser(int $userId): void
{
$this->modalUser = User::find($userId);
if ($this->modalUser) {
$this->modalUserComments = $this->modalUser->comments()
->orderBy('created_at', 'desc')
->limit(10)
->get();
$this->showUserModal = true;
}
}
public function closeModal(): void
{
$this->showUserModal = false;
$this->modalUser = null;
$this->modalUserComments = [];
}
public function banUser(int $userId): void
{
$user = User::findOrFail($userId);
$user->addRole(UserRole::BANNED);
cache()->flush(); cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function unbanUser(int $userId): void
{
$user = User::findOrFail($userId);
$user->removeRole(UserRole::BANNED);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been unbanned.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function grantModerator(int $userId): void
{
$user = User::findOrFail($userId);
$user->addRole(UserRole::MODERATOR);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been granted Moderator role.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function revokeModerator(int $userId): void
{
$user = User::findOrFail($userId);
$user->removeRole(UserRole::MODERATOR);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "Moderator role revoked from {$user->name}.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function deleteUserComments(int $userId): void
{
$user = User::findOrFail($userId);
Comment::where('user_id', $user->id)->delete();
cache()->flush();
$this->dispatch('notify', type: 'success', message: "All comments from {$user->name} have been deleted.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUserComments = collect();
}
}
public function bulkBan(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$user = User::find($userId);
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been banned.");
}
public function bulkUnban(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$user = User::find($userId);
if ($user && $user->hasRole(UserRole::BANNED)) {
$user->removeRole(UserRole::BANNED);
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been unbanned.");
}
public function bulkDeleteComments(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$deleted = Comment::where('user_id', (int) $userId)->delete();
if ($deleted > 0) {
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "Deleted comments from {$count} user(s).");
}
public function getUsersProperty()
{
return User::query()
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
->when($this->email !== '', fn ($query) => $query->where('email', 'like', '%'.$this->email.'%'))
->when(! empty($this->roleFilter), function ($query) {
foreach ($this->roleFilter as $role) {
$query->whereJsonContains('roles', $role);
}
})
->orderBy($this->sortField, $this->sortDirection)
->paginate((int) $this->perPage);
} }
public function render() public function render()
{ {
$users = User::when($this->patreon !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::SUPPORTER->value))
->when($this->banned !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::BANNED->value))
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
->paginate(20);
return view('livewire.admin-user-search', [ return view('livewire.admin-user-search', [
'users' => $users, 'users' => $this->users,
]); ]);
} }
} }
+10
View File
@@ -23,11 +23,21 @@ class DownloadButton extends Component
public $fileExtension = 'HEVC'; public $fileExtension = 'HEVC';
public $version = '';
public function mount() public function mount()
{ {
if (str_contains($this->downloadUrl, 'AV1')) { if (str_contains($this->downloadUrl, 'AV1')) {
$this->fileExtension = 'AV1'; $this->fileExtension = 'AV1';
} }
if (str_contains($this->downloadUrl, 'v2')) {
$this->version = 'v2';
}
if (str_contains($this->downloadUrl, 'v3')) {
$this->version = 'v3';
}
} }
public function clicked($downloadId) public function clicked($downloadId)
+1
View File
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
if ($this->navSearch != '') { if ($this->navSearch != '') {
$episodes = Episode::search($this->navSearch) $episodes = Episode::search($this->navSearch)
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota'])) ->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->query(fn ($query) => $query->with(['gallery', 'studio']))
->take(7) ->take(7)
->get(); ->get();
} }
+51
View File
@@ -26,6 +26,10 @@ class PlaylistOverview extends Component
public Collection $playlistEpisodes; public Collection $playlistEpisodes;
public bool $editingName = false;
public string $editingPlaylistName = '';
public function boot(PlaylistService $playlistService) public function boot(PlaylistService $playlistService)
{ {
$this->playlistService = $playlistService; $this->playlistService = $playlistService;
@@ -112,6 +116,53 @@ class PlaylistOverview extends Component
$this->refreshEpisodes(); $this->refreshEpisodes();
} }
public function editName()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
return;
}
$this->editingPlaylistName = $this->playlist->name;
$this->editingName = true;
}
public function cancelEditName()
{
$this->editingName = false;
$this->editingPlaylistName = '';
}
public function updateName()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
return;
}
$this->validate([
'editingPlaylistName' => 'required|max:30',
]);
$this->playlist->update([
'name' => $this->editingPlaylistName,
]);
$this->editingName = false;
$this->editingPlaylistName = '';
}
public function toggleVisibility()
{
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
return;
}
$this->playlist->update([
'is_private' => ! $this->playlist->is_private,
]);
$this->playlist->refresh();
}
public function render() public function render()
{ {
return view('livewire.playlist-overview', [ return view('livewire.playlist-overview', [
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Livewire;
use App\Enums\UserRole;
use App\Models\User;
use App\Services\SubscriptionService;
use Livewire\Component;
use Livewire\Attributes\Computed;
use Illuminate\Support\Facades\RateLimiter;
class UserSubscription extends Component
{
public $userId = 0;
public $subscriptionKey = '';
public $isActive = false;
protected $rules = [
'subscriptionKey' => 'required|string|size:48',
];
public function mount(User $user)
{
$this->userId = $user ? $user->id : auth()->user()->id;
$this->subscriptionKey = $user->subscription_key ?? '';
$this->isActive = $user->hasRole(UserRole::SUPPORTER) ?? false;
}
public function applyKey(SubscriptionService $subscriptionService)
{
$this->validate();
$rateLimitKey = "apply-subscription:{$this->userId}";
$rateLimitMinutes = 60 * 5; // 5 minutes
// Rate Limit to prevent users trying random keys
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('subscriptionKey', "Too many attempts. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
// Check if token is already being used
$alreadyUsed = User::where('subscription_key', $this->subscriptionKey)
->whereNot('id', $this->userId)
->exists();
if ($alreadyUsed) {
$this->addError('subscriptionKey', 'Key already used!');
return;
}
$user = User::where('id', $this->userId)->firstOrFail();
// Verify token
$success = $subscriptionService->checkSubscriptionStatus($user, $this->subscriptionKey);
if (!$success) {
$this->addError('subscriptionKey', 'Invalid Key! If you believe this is a bug, please report this to the admin!');
return;
}
$user->subscription_key = $this->subscriptionKey;
$user->save();
$this->isActive = true;
}
public function render()
{
return view('livewire.user-subscription');
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use Livewire\Component;
class ViewCount extends Component
{
public $episodeId = 0;
public $viewCount = 0;
public function mount(Episode $episode)
{
$this->episodeId = $episode->id;
$this->viewCount = $episode->view_count;
}
public function update()
{
$this->viewCount = Episode::where('id', $this->episodeId)->firstOrFail()->view_count;
}
public function render()
{
return view('livewire.view-count');
}
}
+20
View File
@@ -7,6 +7,26 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Playlist extends Model class Playlist extends Model
{ {
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'name',
'is_private',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_private' => 'boolean',
];
/** /**
* Belongs To A User. * Belongs To A User.
*/ */
-2
View File
@@ -31,7 +31,6 @@ class User extends Authenticatable implements HasPasskeys
// Discord // Discord
'discord_id', 'discord_id',
'discord_avatar', 'discord_avatar',
'subscription_key',
]; ];
/** /**
@@ -42,7 +41,6 @@ class User extends Authenticatable implements HasPasskeys
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'subscription_key',
]; ];
/** /**
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VideoEngagement extends Model
{
public $table = 'video_engagement';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = ['episode_id', 'user_id', 'segment'];
/**
* Get the Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class, 'episode_id');
}
/**
* Get the User.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+84 -7
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\Studios; use App\Models\Studios;
use App\Models\ModLog;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -62,6 +63,68 @@ class EpisodeService
return $episode; return $episode;
} }
private function applyTags(Request $request, Episode $episode): void
{
$tags = json_decode($request->input('tags'));
$newtags = [];
foreach ($tags as $t) {
$newtags[] = $t->value;
}
$newTagsTemp = $newtags;
$oldTagsTemp = $episode->tagNames();
sort($newTagsTemp);
sort($oldTagsTemp);
if ($newTagsTemp !== $oldTagsTemp) {
ModLog::create([
'moderator' => $request->user()->name,
'data' => sprintf(
'Updated Episode tags from %s to %s',
implode(', ', $oldTagsTemp),
implode(', ', $newTagsTemp),
),
]);
}
$episode->retag($newtags);
}
private function updateTitle(Request $request, Episode $episode): void
{
$updates = [];
if ($episode->title !== $request->input('title')) {
$updates['title'] = $request->input('title');
$updates['title_search'] = preg_replace(
'/[^A-Za-z0-9 ]/',
'',
$request->input('title')
);
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updating Hentai Title from {$episode->title} to {$request->input('title')}",
]);
}
if ($episode->title_jpn !== $request->input('title_jpn')) {
$updates['title_jpn'] = $request->input('title_jpn');
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updating Hentai Title from {$episode->title_jpn} to {$request->input('title_jpn')}",
]);
}
if (! empty($updates)) {
$episode->hentai->episodes()->update($updates);
}
}
public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode
{ {
$episode = Episode::where('id', $episodeId)->firstOrFail(); $episode = Episode::where('id', $episodeId)->firstOrFail();
@@ -75,17 +138,31 @@ class EpisodeService
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true'; $episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
$episode->save(); $episode->save();
// Tagging $this->applyTags($request, $episode);
$tags = json_decode($request->input('tags')); $this->updateTitle($request, $episode);
$newtags = [];
foreach ($tags as $t) {
$newtags[] = $t->value;
}
$episode->retag($newtags);
return $episode; return $episode;
} }
public function updateEpisodeModerator(Request $request, int $episodeId): void
{
$episode = Episode::where('id', $episodeId)->firstOrFail();
$oldDescription = $episode->description;
$episode->description = $request->input('description');
$episode->save();
if ($episode->description !== $oldDescription) {
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updated Episode description from {$oldDescription} to {$episode->description}",
]);
}
$this->applyTags($request, $episode);
$this->updateTitle($request, $episode);
}
public function getOrCreateStudio(string $studioName): Studios public function getOrCreateStudio(string $studioName): Studios
{ {
return Studios::firstOrCreate( return Studios::firstOrCreate(
-79
View File
@@ -1,79 +0,0 @@
<?php
namespace App\Services;
use App\Enums\UserRole;
use App\Models\User;
use Exception;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class SubscriptionService
{
private function generateEncryptedPayload(string $subscriptionKey): string
{
return base64_encode(Crypt::encryptString(json_encode([
'subscription_access_key' => $subscriptionKey,
'timestamp' => now()->timestamp,
'nonce' => Str::uuid()->toString(),
], JSON_THROW_ON_ERROR)));
}
/**
* Gets the subscription status from the subscription service.
*/
private function getSubscriptionStatus(string $subscriptionKey): array | null
{
try {
$payload = $this->generateEncryptedPayload($subscriptionKey);
$response = Http::post(config('services.subscription_service_host').'/api/membership/verify', [
'payload' => $payload,
]);
if (! $response->successful()) {
logger()->error('Subscription Service API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$encryptedResponse = $response->json('payload');
$json = json_decode(
Crypt::decryptString($encryptedResponse),
true,
flags: JSON_THROW_ON_ERROR
);
return $json;
} catch (Exception $e) {
logger()->error('getSubscriptionStatus Exception', [
'details' => $e,
]);
}
return null;
}
public function checkSubscriptionStatus(User $user, string $subscriptionKey): bool
{
$subscriptionStatus = $this->getSubscriptionStatus($subscriptionKey);
if (!$subscriptionStatus) {
return false;
}
if ($subscriptionStatus['valid'] === true &&
$subscriptionStatus['active'] === true) {
$user->addRole(UserRole::SUPPORTER);
return true;
}
$user->removeRole(UserRole::SUPPORTER);
return true;
}
}
Generated
+430 -427
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -54,9 +54,4 @@ return [
'server' => env('MATRIX_SERVER'), 'server' => env('MATRIX_SERVER'),
'shared_secret' => env('MATRIX_SHARED_SECRET'), 'shared_secret' => env('MATRIX_SHARED_SECRET'),
], ],
/**
* Subscription Service
*/
'subscription_service_host' => env('SUBSCRIPTION_SERVICE_HOST'),
]; ];
@@ -0,0 +1,31 @@
<?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('users', function (Blueprint $table) {
$table->dropColumn('subscription_key');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('subscription_key', 64)
->unique()
->nullable()
->after('roles');
});
}
};
@@ -0,0 +1,33 @@
<?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::create('video_engagement', function (Blueprint $table) {
$table->id();
$table->foreignId('episode_id')->constrained('episodes')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->unsignedSmallInteger('segment');
$table->timestamps();
// One row per user per episode per segment — no duplicates
$table->unique(['episode_id', 'user_id', 'segment']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('video_engagement');
}
};
+470 -488
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"plyr": "^3.7.8", "plyr": "^3.8.4",
"tw-elements": "^1.1.0", "tw-elements": "^1.1.0",
"vidstack": "^1.12.13" "vidstack": "^1.12.13"
} }
+33 -48
View File
@@ -1,4 +1,5 @@
@import "@fortawesome/fontawesome-free/css/all.css"; @import "@fortawesome/fontawesome-free/css/all.css";
@import './player.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -8,29 +9,6 @@
--breakpoint-xs: 30rem; --breakpoint-xs: 30rem;
} }
/* Player */
.plyr--full-ui input[type="range"] {
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr__control--overlaid {
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr--video .plyr__control.plyr__tab-focus,
.plyr--video .plyr__control:hover,
.plyr--video .plyr__control[aria-expanded="true"] {
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]::before {
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr--full-ui {
border-radius: 15px;
}
/* Player Ambient */ /* Player Ambient */
.decoy { .decoy {
position: absolute; position: absolute;
@@ -50,31 +28,6 @@ input:checked~.dot {
transform: translateX(100%); transform: translateX(100%);
} }
#plyr__time_skip {
background: #c61e54;
border: 0;
border-radius: 50%;
color: #fff;
left: 50%;
min-width: 60px;
width: min-content;
max-width: 100px;
max-height: 90px;
opacity: 0;
display: table-cell;
text-align: center;
vertical-align: middle;
transform: translate(-50%, -50%);
padding-top: 15px;
padding-bottom: 15px;
position: absolute;
top: 50%;
transition: 1s;
z-index: 3;
pointer-events: none;
box-shadow: 0px 0px 45px #000000;
}
/* DL Button Glow */ /* DL Button Glow */
.hover\:glow:hover { .hover\:glow:hover {
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5)); filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
@@ -128,3 +81,35 @@ input:checked~.dot {
:root { :root {
color-scheme: light dark; 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;
}
}
+946
View File
@@ -0,0 +1,946 @@
/* ============================================================
HStream Custom Video Player
Dark-theme-first with rose-red accents (#c61e54)
============================================================ */
/* ---- Player Wrapper ---- */
.hstream-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 12px;
overflow: visible;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-family: 'Figtree', sans-serif;
direction: ltr;
isolation: isolate;
}
.hstream-player * {
box-sizing: border-box;
}
/* ---- Video Element ---- */
.hstream-player__video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
background: #000;
border-radius: 12px;
overflow: hidden;
touch-action: manipulation;
}
/* ---- Poster Overlay ---- */
.hstream-player__poster {
position: absolute;
inset: 0;
z-index: 1;
background: #000;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.hstream-player__poster img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 1;
transition: opacity 0.3s ease;
}
.hstream-player__poster--hidden {
pointer-events: none;
opacity: 0;
visibility: hidden;
}
/* ---- Large Play Overlay ---- */
.hstream-player__play-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.hstream-player__play-overlay--visible {
opacity: 1;
}
.hstream-player__play-btn {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(198, 30, 84, 0.85);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 2px solid rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
cursor: pointer;
pointer-events: auto;
transition: transform 0.15s ease, background 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 0 30px rgba(198, 30, 84, 0.4), 0 4px 20px rgba(0, 0, 0, 0.5);
}
.hstream-player__play-btn:hover {
transform: scale(1.1);
background: rgba(225, 29, 72, 0.9);
box-shadow: 0 0 40px rgba(198, 30, 84, 0.6), 0 4px 25px rgba(0, 0, 0, 0.5);
}
.hstream-player__play-btn:active {
transform: scale(0.95);
}
/* ---- Controls Container ---- */
.hstream-player__controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: linear-gradient(to top, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.75) 60%, transparent);
padding: 40px 12px 10px 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
transition: opacity 0.15s ease, transform 0.12s ease;
border-radius: 0 0 12px 12px;
}
.hstream-player__controls--hidden {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
/* ---- Progress Section (full width row) ---- */
.hstream-player__progress-wrapper {
width: 100%;
padding: 6px 0;
cursor: pointer;
position: relative;
}
.hstream-player__progress {
position: relative;
height: 6px;
width: 100%;
border-radius: 3px;
background: rgba(255, 255, 255, 0.12);
transition: height 0.15s ease, margin-top 0.15s ease;
overflow: visible;
}
.hstream-player__progress:hover,
.hstream-player__progress--dragging {
height: 8px;
border-radius: 4px;
}
.hstream-player__progress--dragging .hstream-player__progress-thumb {
transform: translate(-50%, -50%) scale(1.2);
}
.hstream-player__progress-buffer {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: inherit;
background: rgba(255, 255, 255, 0.18);
transition: width 0.2s ease;
pointer-events: none;
z-index: 1;
}
.hstream-player__progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(to right, #c61e54, #e11d48);
transition: width 0.1s linear;
pointer-events: none;
z-index: 2;
}
.hstream-player__progress-heatmap {
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 32px;
pointer-events: none;
z-index: 0;
}
.hstream-player__progress-heatmap-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hstream-player__progress-thumb {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
border: 2px solid #c61e54;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.15s ease;
z-index: 4;
pointer-events: none;
box-shadow: 0 0 8px rgba(198, 30, 84, 0.5);
}
.hstream-player__progress:hover .hstream-player__progress-thumb,
.hstream-player__progress-wrapper--active .hstream-player__progress-thumb {
transform: translate(-50%, -50%) scale(1);
}
/* ---- Time Tooltip ---- */
.hstream-player__time-tooltip {
position: absolute;
bottom: calc(100% + 8px);
transform: translateX(-50%);
background: rgba(10, 10, 10, 0.9);
color: #fff;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 5;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.hstream-player__time-tooltip--visible {
opacity: 1;
}
/* ---- Thumbnail Preview ---- */
.hstream-player__thumbnail-preview {
position: absolute;
bottom: calc(100% + 16px);
transform: translateX(-50%);
background: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
overflow: hidden;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 5;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
.hstream-player__thumbnail-preview--visible {
opacity: 1;
}
.hstream-player__thumbnail-preview-img {
display: block;
object-fit: cover;
object-position: center;
}
.hstream-player__thumbnail-preview-time {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 10px;
text-align: center;
font-weight: 600;
}
/* ---- Buttons ---- */
.hstream-player__button {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
color: #fff;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
padding: 0;
transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
flex-shrink: 0;
}
.hstream-player__button:hover {
background: rgba(255, 255, 255, 0.08);
}
.hstream-player__button:active {
transform: scale(0.9);
}
.hstream-player__button--active {
color: #c61e54;
}
.hstream-player__button svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* ---- Time Display ---- */
.hstream-player__time-display {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
padding: 0 2px;
}
.hstream-player__time-separator {
opacity: 0.5;
margin: 0 1px;
}
/* ---- Volume Wrapper ---- */
.hstream-player__volume-wrapper {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
height: 36px;
}
.hstream-player__volume-slider-container {
width: 0;
overflow: hidden;
transition: width 0.2s ease;
display: flex;
align-items: center;
padding: 6px 0;
margin: -6px 0;
}
.hstream-player__volume-wrapper:hover .hstream-player__volume-slider-container {
width: 60px;
}
.hstream-player--mobile .hstream-player__volume-slider-container {
width: 80px;
overflow: visible;
}
.hstream-player__volume-slider {
-webkit-appearance: none !important;
appearance: none !important;
accent-color: #c61e54 !important;
width: 56px !important;
height: 4px !important;
border-radius: 2px !important;
outline: none !important;
cursor: pointer !important;
margin: 0 !important;
padding: 0 !important;
vertical-align: middle !important;
background: transparent !important;
border: none !important;
}
.hstream-player__volume-slider:focus {
outline: none !important;
}
.hstream-player__volume-slider::-webkit-slider-runnable-track {
height: 4px !important;
border-radius: 2px !important;
background: linear-gradient(
to right,
#c61e54 0%,
#c61e54 var(--volume-pct, 50%),
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
rgba(255, 255, 255, 0.15) 100%
) !important;
}
.hstream-player__volume-slider::-webkit-slider-thumb {
-webkit-appearance: none !important;
appearance: none !important;
width: 13px !important;
height: 13px !important;
border-radius: 50% !important;
background: #c61e54 !important;
margin-top: -5px !important;
cursor: pointer !important;
}
.hstream-player__volume-slider::-moz-range-track {
height: 4px !important;
border-radius: 2px !important;
background: rgba(255, 255, 255, 0.15) !important;
border: none !important;
}
.hstream-player__volume-slider::-moz-range-progress {
height: 4px !important;
border-radius: 2px !important;
background: #c61e54 !important;
border: none !important;
}
.hstream-player__volume-slider::-moz-range-thumb {
width: 13px !important;
height: 13px !important;
border-radius: 50% !important;
background: #c61e54 !important;
cursor: pointer !important;
}
/* ---- Spacer ---- */
.hstream-player__spacer {
flex: 1;
min-width: 4px;
}
/* ---- Settings Menu ---- */
.hstream-player__menu-container {
position: absolute;
right: 12px;
bottom: 80px;
min-width: 220px;
max-width: 280px;
background: rgba(20, 20, 20, 0.70);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
overflow: hidden;
z-index: 20;
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.hstream-player__menu-container--open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.hstream-player__menu-panel {
display: none;
}
.hstream-player__menu-panel--active {
display: block;
}
.hstream-player__menu-back {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
background: transparent;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease;
}
.hstream-player__menu-back:hover {
background: rgba(255, 255, 255, 0.04);
color: #fff;
}
.hstream-player__menu-back i {
font-size: 10px;
}
.hstream-player__menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: transparent;
border: 0;
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, color 0.15s ease;
text-align: left;
}
.hstream-player__menu-item:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.hstream-player__menu-item--checked {
color: #c61e54;
}
.hstream-player__menu-item-radio {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 8px;
}
.hstream-player__menu-item--checked .hstream-player__menu-item-radio {
border-color: #c61e54;
}
.hstream-player__menu-item--checked .hstream-player__menu-item-radio::after {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: #c61e54;
}
.hstream-player__menu-value {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
margin-left: auto;
white-space: nowrap;
}
.hstream-player__menu-item > span:first-of-type {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
margin-right: 8px;
}
.hstream-player__menu-item--checked .hstream-player__menu-value {
color: #c61e54;
}
.hstream-player__menu-badge {
display: inline-block;
background: #c61e54;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
text-transform: uppercase;
}
.hstream-player__menu-divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 4px 0;
}
/* ---- Skip Overlay (Mobile) ---- */
.hstream-player__skip-overlay {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 3;
pointer-events: none;
opacity: 0;
transition: opacity 0.5s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(198, 30, 84, 0.7);
border-radius: 50%;
width: 70px;
height: 70px;
color: #fff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
}
.hstream-player__skip-overlay--visible {
opacity: 0.9;
}
.hstream-player__skip-overlay i {
font-size: 22px;
margin-bottom: 2px;
}
/* ---- Loading Spinner ---- */
.hstream-player__loading {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.hstream-player__loading--visible {
opacity: 1;
}
.hstream-player__spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.15);
border-top-color: #c61e54;
border-radius: 50%;
animation: hstream-spin 0.8s linear infinite;
}
@keyframes hstream-spin {
to { transform: rotate(360deg); }
}
/* ---- Player Switcher Toggle ---- */
.player-switcher {
position: absolute;
top: 12px;
right: 12px;
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
background: rgba(10, 10, 10, 0.65);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 9999px;
padding: 6px 4px 6px 14px;
transition: all 0.3s ease;
opacity: 0;
pointer-events: none;
}
@media (hover: hover) {
.aspect-\[16\/9\]:hover .player-switcher {
opacity: 1;
pointer-events: auto;
}
}
.player-switcher--visible {
opacity: 1;
pointer-events: auto;
}
.player-switcher__label {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.player-switcher__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9999px;
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'Figtree', sans-serif;
white-space: nowrap;
}
.player-switcher__btn:hover {
background: rgba(198, 30, 84, 0.2);
border-color: rgba(198, 30, 84, 0.4);
color: #fff;
}
.player-switcher__btn i {
font-size: 11px;
}
.player-switcher__btn--active {
background: rgba(198, 30, 84, 0.25);
border-color: rgba(198, 30, 84, 0.5);
color: #fff;
}
.player-switcher__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #c61e54;
box-shadow: 0 0 6px rgba(198, 30, 84, 0.6);
}
/* ---- Fullscreen (remove rounded borders) ---- */
.hstream-player:fullscreen,
.hstream-player:-webkit-full-screen {
border-radius: 0;
}
.hstream-player:fullscreen .hstream-player__video,
.hstream-player:-webkit-full-screen .hstream-player__video,
.hstream-player:fullscreen .hstream-player__poster,
.hstream-player:-webkit-full-screen .hstream-player__poster,
.hstream-player:fullscreen .hstream-player__controls,
.hstream-player:-webkit-full-screen .hstream-player__controls {
border-radius: 0;
}
/* ---- Idle State (hide controls & cursor) ---- */
.hstream-player--idle {
cursor: none;
}
.hstream-player--idle .hstream-player__controls {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
/* ---- Mobile Responsive ---- */
@media (max-width: 767px) {
.hstream-player {
border-radius: 0;
}
.hstream-player__controls {
padding: 36px 4px 8px 4px;
gap: 2px;
border-radius: 0;
}
.hstream-player__button {
width: 40px;
height: 40px;
font-size: 16px;
border-radius: 8px;
}
.hstream-player__time-display {
font-size: 12px;
}
.hstream-player__play-btn {
width: 60px;
height: 60px;
font-size: 24px;
}
.hstream-player__menu-container {
right: 8px;
min-width: 200px;
max-width: 240px;
}
.hstream-player__progress-wrapper {
padding: 8px 0;
}
.hstream-player__progress {
height: 5px;
}
.hstream-player__progress-thumb {
width: 16px;
height: 16px;
}
.hstream-player__skip-overlay {
width: 60px;
height: 60px;
font-size: 12px;
}
.hstream-player__skip-overlay i {
font-size: 18px;
}
.hstream-player__thumbnail-preview {
display: none !important;
}
.hstream-player__volume-slider-container {
width: 60px;
overflow: visible;
}
.hstream-player__volume-slider {
accent-color: #c61e54 !important;
width: 56px !important;
height: 6px !important;
border-radius: 3px !important;
}
.hstream-player__volume-slider::-webkit-slider-runnable-track {
height: 6px !important;
border-radius: 3px !important;
background: linear-gradient(
to right,
#c61e54 0%,
#c61e54 var(--volume-pct, 50%),
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
rgba(255, 255, 255, 0.15) 100%
) !important;
}
.hstream-player__volume-slider::-webkit-slider-thumb {
width: 18px !important;
height: 18px !important;
margin-top: -6px !important;
}
.hstream-player__volume-slider::-moz-range-track {
height: 6px !important;
border-radius: 3px !important;
background: rgba(255, 255, 255, 0.15) !important;
border: none !important;
}
.hstream-player__volume-slider::-moz-range-progress {
height: 6px !important;
border-radius: 3px !important;
background: #c61e54 !important;
border: none !important;
}
.hstream-player__volume-slider::-moz-range-thumb {
width: 18px !important;
height: 18px !important;
}
}
@media (max-width: 380px) {
.hstream-player__time-display {
display: none;
}
.hstream-player__button {
width: 36px;
height: 36px;
}
}
/* ============================================================
Plyr Player Overrides pink accent, rounded corners, skip overlay
============================================================ */
.plyr--full-ui input[type='range'] {
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr__control--overlaid {
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr--video .plyr__control.plyr__tab-focus,
.plyr--video .plyr__control:hover,
.plyr--video .plyr__control[aria-expanded='true'] {
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
}
.plyr--full-ui {
border-radius: 15px;
}
/* Plyr Engagement Heatmap, overlays the progress bar track */
.plyr__progress__heatmap {
position: absolute;
left: -6px;
right: -6px;
bottom: 40%;
height: 48px;
pointer-events: none;
z-index: 0;
}
.plyr__progress__heatmap-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* Plyr Mobile Double-tap Skip Overlay */
#plyr__time_skip {
background: #c61e54;
border: 0;
border-radius: 50%;
color: #fff;
left: 50%;
min-width: 60px;
width: min-content;
max-width: 100px;
max-height: 90px;
opacity: 0;
display: table-cell;
text-align: center;
vertical-align: middle;
transform: translate(-50%, -50%);
padding-top: 15px;
padding-bottom: 15px;
position: absolute;
top: 50%;
transition: 1s;
z-index: 3;
pointer-events: none;
box-shadow: 0px 0px 45px #000000;
}
+44 -13
View File
@@ -1,14 +1,10 @@
if (document.getElementById("playlist-add")) { if (document.getElementById("playlist-add")) {
function createPlaylist() { function addToPlaylist() {
console.log('Adding to Playlist: ' + document.querySelector("#playlist").value)
window.axios.post('/hentai/add-to-playlist', { window.axios.post('/hentai/add-to-playlist', {
playlist: document.getElementById('playlist').value, playlist: document.getElementById('playlist').value,
episode_id: document.getElementById('e_id').value episode_id: document.getElementById('e_id').value
}).then(function (response) { }).then(function (response) {
if (response.status == 200) { if (response.status == 200) {
document.getElementById("playlist-cancel").click();
if (response.data.message == 'already-added') { if (response.data.message == 'already-added') {
Swal.fire({ Swal.fire({
title: "Already added!", title: "Already added!",
@@ -18,6 +14,8 @@ if (document.getElementById("playlist-add")) {
} }
if (response.data.message == 'success') { if (response.data.message == 'success') {
document.getElementById("playlist-cancel").click();
Swal.fire({ Swal.fire({
title: "Success!", title: "Success!",
text: "Added episode to the playlist!", text: "Added episode to the playlist!",
@@ -27,33 +25,66 @@ if (document.getElementById("playlist-add")) {
} }
}).catch(function (error) { }).catch(function (error) {
console.log(error); console.log(error);
Swal.fire({
title: "Error!",
text: "Could not add episode to playlist.",
icon: "error"
});
}); });
} }
document.querySelector("#playlist-add").addEventListener("click", createPlaylist); document.querySelector("#playlist-add").addEventListener("click", addToPlaylist);
} }
if (document.getElementById("playlist-create-and-add")) { if (document.getElementById("playlist-create-and-add")) {
function createAndAddPlaylist() { function createAndAddPlaylist() {
const nameField = document.getElementById('playlist-name');
const visibilityField = document.getElementById('playlist-visibility');
if (!nameField.value.trim()) {
Swal.fire({
title: "Name required!",
text: "Please enter a playlist name.",
icon: "warning"
});
return;
}
window.axios.post('/hentai/create-playlist', { window.axios.post('/hentai/create-playlist', {
name: document.getElementById('name').value, name: nameField.value,
visiblity: document.getElementById('visiblity').value visiblity: visibilityField.value
}).then(function (response) { }).then(function (response) {
window.axios.post('/hentai/add-to-playlist', { window.axios.post('/hentai/add-to-playlist', {
playlist: response.data.playlist_id, playlist: response.data.playlist_id,
episode_id: document.getElementById('e_id').value episode_id: document.getElementById('e_id').value
}).then(function (response) { }).then(function (addResponse) {
if (response.status == 200) { if (addResponse.status == 200) {
document.getElementById("playlist-cancel").click(); const cancelBtn = document.getElementById("playlist-cancel");
if (cancelBtn) cancelBtn.click();
Swal.fire({
title: "Success!",
text: "Playlist created and episode added!",
icon: "success"
});
} }
}).catch(function (error) { }).catch(function (error) {
console.log(error); console.log(error);
Swal.fire({
title: "Error!",
text: "Could not add episode to the new playlist.",
icon: "error"
});
}); });
}).catch(function (error) { }).catch(function (error) {
console.log(error); console.log(error);
Swal.fire({
title: "Error!",
text: "Could not create playlist.",
icon: "error"
});
}); });
} }
document.querySelector("#playlist-create-and-add").addEventListener("click", createAndAddPlaylist); document.querySelector("#playlist-create-and-add").addEventListener("click", createAndAddPlaylist);
} }
+72
View File
@@ -0,0 +1,72 @@
// Engagement heatmap tracking
// Samples the user's current time while playing and sends batched segment data to the server.
// Only tracks segment >= 1 (excludes 0-10s).
// Only calls the endpoint when the user is logged in.
let engagementInterval;
let engagementSegments = new Set();
let engagementReportInterval;
const SEGMENT_DURATION = 10; // seconds per segment
const SAMPLE_INTERVAL = 5000; // sample every 5s
const REPORT_INTERVAL = 15000; // send batch every 15s
function isAuthenticated() {
const el = document.getElementById('auth_check');
return el && el.value === '1';
}
function sendEngagement(episodeId, segments) {
if (!isAuthenticated()) return;
window.axios.post('/player/engagement', {
episode_id: episodeId,
segments: segments,
}).catch(() => {
// Fire-and-forget: silently ignore network errors
});
}
export function startEngagementTracking(episodeId) {
engagementSegments.clear();
// Sample current time while playing
engagementInterval = setInterval(() => {
const video = document.querySelector('video');
if (!video || video.paused) return;
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
// Skip segment 0 (0-10s) — no need to track the very start
if (segment >= 1) {
engagementSegments.add(segment);
}
}, SAMPLE_INTERVAL);
// Batch report to server
engagementReportInterval = setInterval(() => {
if (engagementSegments.size === 0) return;
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}, REPORT_INTERVAL);
// Flush remaining segments & cleanup on page unload
const cleanup = () => {
clearInterval(engagementInterval);
clearInterval(engagementReportInterval);
if (engagementSegments.size > 0) {
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}
};
window.addEventListener('beforeunload', cleanup);
}
export function stopEngagementTracking() {
if (engagementInterval) clearInterval(engagementInterval);
if (engagementReportInterval) clearInterval(engagementReportInterval);
}
+208
View File
@@ -0,0 +1,208 @@
// Engagement heatmap display
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
let heatmapContainer = null;
let heatmapCanvas = null;
let heatmapResizeObserver = null;
/**
* Fetch engagement data from the server and render the heatmap.
* @param {string} episodeId - The episode ID.
* @param {number} duration - Video duration in seconds.
*/
export async function renderHeatmap(episodeId, duration) {
try {
const response = await window.axios.get(`/player/engagement/${episodeId}`);
const data = response.data;
if (!data || Object.keys(data).length === 0) {
return;
}
drawHeatmapCurve(data, duration);
} catch (error) {
console.error('Failed to load engagement data:', error);
}
}
/**
* Draw a smooth area chart on a canvas element above the progress bar.
* @param {Object} data - Key-value map of segment -> watch_count.
* @param {number} duration - Video duration in seconds.
*/
function drawHeatmapCurve(data, duration) {
const SEGMENT_DURATION = 10;
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
// Build raw counts array, filling gaps with 0
const rawCounts = [];
for (let i = 0; i < totalSegments; i++) {
rawCounts.push(data[i] || 0);
}
// Apply 3-point moving average to smooth individual spikes
const counts = smoothData(rawCounts);
const maxCount = Math.max(...counts, 1);
// Remove existing heatmap if present
if (heatmapContainer) {
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
heatmapContainer.remove();
heatmapCanvas = null;
}
// Find the progress bar wrapper
const progressBar = document.querySelector('.plyr__progress');
if (!progressBar) return;
// Create container
heatmapContainer = document.createElement('div');
heatmapContainer.className = 'plyr__progress__heatmap';
heatmapContainer.setAttribute('aria-hidden', 'true');
// Create canvas
heatmapCanvas = document.createElement('canvas');
heatmapCanvas.className = 'plyr__progress__heatmap-canvas';
heatmapContainer.appendChild(heatmapCanvas);
// Insert as first child of the progress bar so it sits behind the scrubber
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
// Defer drawing to get container dimensions
requestAnimationFrame(() => {
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
});
// Redraw on resize
heatmapResizeObserver = new ResizeObserver(() => {
drawCurve(heatmapCanvas, counts, maxCount);
});
heatmapResizeObserver.observe(heatmapContainer);
}
/**
* Apply a 3-point moving average to smooth out jaggedness.
* Preserves the first and last points.
*/
function smoothData(data) {
if (data.length <= 2) return [...data];
const smoothed = [data[0]]; // preserve first
for (let i = 1; i < data.length - 1; i++) {
smoothed.push((data[i - 1] + data[i] + data[i + 1]) / 3);
}
smoothed.push(data[data.length - 1]); // preserve last
return smoothed;
}
/**
* Render vertical bar heatmap directly on the progress bar track.
* Each bar represents a time segment; taller bars = more engagement.
*/
function drawCurve(canvas, counts, maxCount) {
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = rect.width;
const h = rect.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (counts.length === 0 || maxCount === 0) return;
// Padding: leave 1px on each side so the curve doesn't clip at edges
const paddingX = 1;
const paddingY = 1;
const drawW = w - paddingX * 2;
const drawH = h - paddingY * 2;
const baseline = paddingY + drawH / 2; // curve oscillates around the center
const amplitude = (drawH / 2) * 0.8; // 80% of half-height to keep inside bounds
const n = counts.length;
// Build data points: x = horizontal position, y = vertical offset from center
const pts = [];
for (let i = 0; i < n; i++) {
const x = paddingX + (i / (n - 1 || 1)) * drawW;
const ratio = counts[i] / maxCount;
// ratio 0 = bottom of amplitude range, ratio 1 = top of amplitude range
const y = baseline - (ratio - 0.5) * amplitude * 2;
pts.push({ x, y });
}
if (pts.length < 2) return;
// Build the smooth path using quadratic bezier curves through midpoints
const path = [{ x: pts[0].x, y: pts[0].y }];
for (let i = 0; i < pts.length - 1; i++) {
const midX = (pts[i].x + pts[i + 1].x) / 2;
const midY = (pts[i].y + pts[i + 1].y) / 2;
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
}
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
// --- Draw a subtle glow behind the line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 3.0;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// --- Draw the main waveform line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
/**
* Remove the heatmap from the DOM.
*/
export function removeHeatmap() {
if (heatmapResizeObserver) {
heatmapResizeObserver.disconnect();
heatmapResizeObserver = null;
}
if (heatmapContainer) {
heatmapContainer.remove();
heatmapContainer = null;
heatmapCanvas = null;
}
}
+436
View File
@@ -0,0 +1,436 @@
// Plyr Fallback Player
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
import * as dashjs from 'dashjs';
import SubtitlesOctopus from '@jellyfin/libass-wasm';
import { initMobileWidescreen } from './player-mobile';
import { mobileDoubleClick } from './player-mobile';
import { playNextPlaylistVideo } from './playlist';
import { addVideoTracks } from './player-data';
import { addSubtitleTracks } from './player-data';
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
import { isIOS } from './detect-ios';
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
import { renderHeatmap } from './player/player-heatmap';
var player = null;
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
var dashSupported = dashjs.supportsMediaSource();
var apiResponse = {};
var volume = 0.5;
var muted = false;
var captions = true;
var lastTime = 0.0;
var streamServer = '';
var streamServers = [];
var streamServerIndex = 0;
var streamServerCount = 0;
var ambientMode = true;
var serverFallback = false;
var saveInterval;
var watchTracked = false;
var subtitleInstance = null;
function trackWatchTime() {
if (watchTracked) return;
var video = document.getElementsByTagName('video')[0];
if (video && video.currentTime >= 10) {
watchTracked = true;
var episodeId = document.getElementById('e_id').value;
window.axios.post('/watched/track', {
episode_id: episodeId
}).then(function () {
console.log('Watch tracked for episode ' + episodeId);
}).catch(function (error) {
console.error('Failed to track watch: ' + error);
});
}
}
var controls = [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'captions',
'settings',
'fullscreen',
];
if (localStorage.hstreamVolume) {
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
console.log('Loaded Audio Volume from Local Storage: ' + volume);
}
if (localStorage.hstreamCaptions) {
captions = (localStorage.getItem('hstreamCaptions') == 'true');
console.log('Loaded Captions Status from Local Storage: ' + captions);
}
if (localStorage.hstreamMuted) {
muted = (localStorage.getItem('hstreamMuted') == 'true');
console.log('Loaded Muted Status from Local Storage: ' + muted);
}
if (localStorage.hstreamServerFallback) {
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
}
if (!av1Supported) {
document.getElementById('av1-unsupported').classList.remove('hidden');
}
function initDash(data, player) {
const video = document.querySelector('video');
data.forEach(function (el) {
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
const dash = dashjs.MediaPlayer().create();
dash.initialize(video, el.src, true);
window.player = player;
window.dash = dash;
}
});
}
function setCanvasDimension(canvas, video) {
canvas.height = video.offsetHeight;
canvas.width = video.offsetWidth;
}
function paintStaticVideo(ctx, video) {
if (localStorage.theme == 'light') {
return;
}
if (!ambientMode) {
return;
}
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
}
function toggleAmbientMode() {
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
if (ambientMode) {
ambientMode = false;
localStorage.ambientMode = 'false';
setCanvasDimension(canvas, video);
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
} else {
ambientMode = true;
localStorage.ambientMode = 'true';
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
}
}
function toggleAsiaServer() {
if (serverFallback) {
serverFallback = false;
localStorage.hstreamServerFallback = 'false';
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
streamServers = apiResponse.stream_domains;
} else {
serverFallback = true;
localStorage.hstreamServerFallback = 'true';
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
streamServers = apiResponse.asia_stream_domains;
}
streamServerCount = streamServers.length;
streamServerIndex = Math.floor(Math.random() * streamServerCount);
streamServer = streamServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
clearInterval(saveInterval);
stopEngagementTracking();
player.destroy();
}
initPlayer();
}
function initSubtitles(lang) {
if (isIOS()) {
return;
}
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
subtitleInstance.dispose();
}
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
if (lang != 'en') {
newSubUrl += 'autotrans/' + lang + '.ass';
} else {
newSubUrl += 'eng.ass';
}
let subFont = '/fonts/Figtree-ExtraBold.woff2';
if (lang == 'hi') {
subFont = '/fonts/Hind-SemiBold.ttf';
}
var options = {
video: document.getElementsByTagName('video')[0],
subUrl: newSubUrl,
workerUrl: '/build/js/subtitles-octopus-worker.js',
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
fonts: [subFont],
renderMode: 'wasm-blend',
};
subtitleInstance = new SubtitlesOctopus(options);
}
function initPlayer() {
player = new Plyr('#player', {
controls,
quality: {
default: 720,
options: [2161, 2160, 1081, 1080, 720]
},
i18n: {
qualityLabel: {
2161: '2160p48',
2160: '2160p',
1081: '1080p48',
1080: '1080p',
720: '720p'
},
qualityBadge: {
2161: 'UHD@48',
1081: 'FHD@48',
1080: 'FHD',
},
},
fullscreen: { enabled: true, fallback: true, iosNative: true }
});
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
player.source = {
type: 'video',
title: apiResponse.title,
poster: apiResponse.poster,
previewThumbnails: {
enabled: true,
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
},
sources: data,
tracks: addSubtitleTracks(streamServer, apiResponse)
};
player.volume = volume;
player.muted = muted;
player.captions.language = 'en';
player.captions.active = captions;
if (dashSupported && !apiResponse.legacy) {
player.on('qualitychange', () => {
initDash(data, player);
});
initDash(data, player);
}
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
var allItems = document.getElementsByClassName('plyr__control--forward');
var lastItem = allItems[allItems.length - 1];
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
if (localStorage.ambientMode == 'false') {
toggleAmbientMode();
}
lastItem = allItems[allItems.length - 1];
let value = 'Off';
if (serverFallback) { value = 'On'; }
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
var clickedPlay = false;
player.on('play', () => {
if (!clickedPlay) {
player.stop();
console.log('Stopped video, because user didn\'t click play.');
}
const episodeId = document.getElementById('e_id').value;
startEngagementTracking(episodeId);
setCanvasDimension(canvas, video);
console.log('Play => Function Loop()');
var $this = video;
(function loop() {
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
setTimeout(loop, 24000 / 1001);
}
})();
});
player.on('seeked', () => {
paintStaticVideo(ctx, video);
if (player.currentTime > 0) {
lastTime = player.currentTime;
}
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
});
window.addEventListener('resize', () => {
setCanvasDimension(canvas, video);
if (player.paused) {
paintStaticVideo(ctx, video);
}
});
player.on('captionsenabled', () => {
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
localStorage.setItem('hstreamCaptions', 'true');
console.log('Set Captions Status to Local Storage: true');
});
player.on('captionsdisabled', () => {
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
localStorage.setItem('hstreamCaptions', 'false');
console.log('Set Captions Status to Local Storage: false');
});
player.on('volumechange', () => {
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
localStorage.setItem('hstreamVolume', player.volume.toString());
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
localStorage.setItem('hstreamMuted', player.muted.toString());
});
player.on('ended', () => {
playNextPlaylistVideo();
});
player.on('timeupdate', () => {
trackWatchTime();
});
player.on('languagechange', (event) => {
let lang = event.detail.plyr.captions.language;
console.log('Subtitle Event ' + lang);
initSubtitles(lang);
});
function playerPlayTemp() {
clickedPlay = true;
}
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
play.addEventListener('click', playerPlayTemp)
);
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
initMobileWidescreen();
setTimeout(function () {
const params = new URLSearchParams(window.location.search);
const time = parseInt(params.get('t'));
if (!isNaN(time)) {
player.currentTime = time;
console.log('Skipping to ' + time);
}
if (lastTime > 0) {
player.currentTime = lastTime;
console.log('Skipping to ' + lastTime);
}
}, 500);
player.on('ready', () => {
mobileDoubleClick(player);
const video = document.querySelector('video');
const episodeId = document.getElementById('e_id').value;
if (video && video.duration) {
renderHeatmap(episodeId, video.duration);
} else if (video) {
video.addEventListener('loadedmetadata', function onMeta() {
video.removeEventListener('loadedmetadata', onMeta);
renderHeatmap(episodeId, video.duration);
});
}
});
var settingElements = document.getElementsByClassName('plyr__control--forward');
if (settingElements.length == 3) {
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
if (settingNodes.length == 4) {
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
}
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
let serverSelects = document.getElementsByClassName('change_server');
for (let i = 0; i < serverSelects.length; i++) {
serverSelects[i].addEventListener('click', function () {
streamServerIndex = Number(this.value);
streamServer = streamServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
clearInterval(saveInterval);
stopEngagementTracking();
player.destroy();
}
initPlayer();
});
}
}
saveInterval = setInterval(function () {
lastTime = player.currentTime;
console.log('Last Player Position: ' + lastTime);
}, 10000);
}
export function initPlyrPlayer(episodeId) {
window.axios.post('/player/api', {
episode_id: episodeId
}).then(function (response) {
if (response.status == 200) {
apiResponse = response.data;
streamServers = apiResponse.stream_domains;
if (serverFallback) {
streamServers = apiResponse.asia_stream_domains;
}
streamServerCount = streamServers.length;
streamServerIndex = Math.floor(Math.random() * streamServerCount);
streamServer = streamServers[streamServerIndex];
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
initPlayer();
}
}).catch(function (error) {
var alert = document.getElementById('player-alert');
if (alert) {
alert.innerText = 'The player encountered a problem: ' + error;
alert.classList.remove('hidden');
}
});
return player;
}
+204 -352
View File
@@ -1,28 +1,17 @@
// Plyr Player // HStream Custom Video Player
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
// Vidstack Player
import 'vidstack/player/styles/default/theme.css'; import 'vidstack/player/styles/default/theme.css';
import 'vidstack/player/styles/default/layouts/video.css'; import 'vidstack/player/styles/default/layouts/video.css';
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player'; import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
// Dash Support
import * as dashjs from 'dashjs'; import * as dashjs from 'dashjs';
// Subtitle Support
import SubtitlesOctopus from '@jellyfin/libass-wasm'; import SubtitlesOctopus from '@jellyfin/libass-wasm';
import { HStreamPlayer } from './player/player-core';
// Custom JS import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/player-mobile';
import { initMobileWidescreen } from './player-mobile';
import { mobileDoubleClick } from './player-mobile'
import { playNextPlaylistVideo } from './playlist'; import { playNextPlaylistVideo } from './playlist';
import { addVideoTracks } from './player-data'; import { addVideoTracks, addSubtitleTracks } from './player/player-data';
import { addSubtitleTracks } from './player-data';
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
import { isIOS } from './detect-ios'; import { isIOS } from './detect-ios';
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
import { renderHeatmap } from './player/player-heatmap';
// Variables
var player = null; var player = null;
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"')); var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
var dashSupported = dashjs.supportsMediaSource(); var dashSupported = dashjs.supportsMediaSource();
@@ -33,124 +22,48 @@ var captions = true;
var lastTime = 0.0; var lastTime = 0.0;
var streamServer = ''; var streamServer = '';
var streamServers = []; var streamServers = [];
var fallbackServers = [];
var streamServerIndex = 0; var streamServerIndex = 0;
var streamServerCount = 0; var streamServerCount = 0;
var ambientMode = true; var ambientMode = true;
var serverFallback = false;
var saveInterval; var saveInterval;
var watchTracked = false;
var subtitleInstance = null; var subtitleInstance = null;
var controls = [ function trackWatchTime() {
'play-large', // The large play button in the center if (watchTracked) return;
'play', // Play/pause playback var videoEl = document.getElementsByTagName('video')[0];
'progress', // The progress bar and scrubber for playback and buffering if (videoEl && videoEl.currentTime >= 10) {
'current-time', // The current time of playback watchTracked = true;
'duration', // The full duration of the media var episodeId = document.getElementById('e_id').value;
'mute', // Toggle mute window.axios.post('/watched/track', {
'volume', // Volume control episode_id: episodeId
'captions', // Toggle captions }).then(function () {
'settings', // Settings menu console.log('Watch tracked for episode ' + episodeId);
'fullscreen', // Toggle fullscreen }).catch(function (error) {
]; console.error('Failed to track watch: ' + error);
});
}
}
// Load Volume from LocalStorage
if (localStorage.hstreamVolume) { if (localStorage.hstreamVolume) {
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2); volume = parseFloat(localStorage.getItem('hstreamVolume'));
if (!isNaN(volume)) volume = Math.max(0, Math.min(1, volume));
console.log('Loaded Audio Volume from Local Storage: ' + volume); console.log('Loaded Audio Volume from Local Storage: ' + volume);
} }
// Load Captions from LocalStorage
if (localStorage.hstreamCaptions) { if (localStorage.hstreamCaptions) {
captions = (localStorage.getItem('hstreamCaptions') == 'true'); captions = (localStorage.getItem('hstreamCaptions') === 'true');
console.log('Loaded Captions Status from Local Storage: ' + captions); console.log('Loaded Captions Status from Local Storage: ' + captions);
} }
// Load Muted from LocalStorage if (localStorage.hstreamMuted) {
if (localStorage.hstreamCaptions) { muted = (localStorage.getItem('hstreamMuted') === 'true');
muted = (localStorage.getItem('hstreamMuted') == 'true');
console.log('Loaded Muted Status from Local Storage: ' + muted); console.log('Loaded Muted Status from Local Storage: ' + muted);
} }
// Asia Server Fallback
if (localStorage.hstreamServerFallback) {
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
}
// Alert User when AV1 is not supported
if (!av1Supported) { if (!av1Supported) {
document.getElementById("av1-unsupported").classList.remove("hidden"); document.getElementById('av1-unsupported').classList.remove('hidden');
}
function initDash(data, player) {
const video = document.querySelector('video');
data.forEach(function (el) {
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
const dash = dashjs.MediaPlayer().create();
dash.initialize(video, el.src, true);
// Expose player and dash so they can be used from the console
window.player = player;
window.dash = dash;
}
});
}
function setCanvasDimension(canvas, video) {
canvas.height = video.offsetHeight;
canvas.width = video.offsetWidth;
}
function paintStaticVideo(ctx, video) {
if (localStorage.theme == 'light') {
return;
}
if (!ambientMode) {
return;
}
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
}
function toggleAmbientMode() {
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
if (ambientMode) {
ambientMode = false;
localStorage.ambientMode = 'false';
setCanvasDimension(canvas, video);
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
} else {
ambientMode = true;
localStorage.ambientMode = 'true';
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
}
}
function toggleAsiaServer() {
if (serverFallback) {
serverFallback = false;
localStorage.hstreamServerFallback = 'false';
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
streamServers = apiResponse.stream_domains;
} else {
serverFallback = true;
localStorage.hstreamServerFallback = 'true';
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
streamServers = apiResponse.asia_stream_domains;
}
streamServerCount = streamServers.length;
streamServerIndex = Math.floor(Math.random() * streamServerCount);
streamServer = streamServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
clearInterval(saveInterval);
player.destroy();
}
initPlayer();
} }
function initSubtitles(lang) { function initSubtitles(lang) {
@@ -158,32 +71,28 @@ function initSubtitles(lang) {
return; return;
} }
// Dispose old instance if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
subtitleInstance.dispose(); subtitleInstance.dispose();
} }
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/'; var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
if (lang != 'en') { if (lang !== 'en') {
newSubUrl += 'autotrans/' + lang + '.ass'; newSubUrl += 'autotrans/' + lang + '.ass';
} } else {
else { newSubUrl += 'eng.ass';
newSubUrl += 'eng.ass'
} }
let subFont = '/fonts/Figtree-ExtraBold.woff2'; var subFont = '/fonts/Figtree-ExtraBold.woff2';
// Hindi font if (lang === 'hi') {
if (lang == 'hi') {
subFont = '/fonts/Hind-SemiBold.ttf'; subFont = '/fonts/Hind-SemiBold.ttf';
} }
// Subtitles
var options = { var options = {
video: document.getElementsByTagName('video')[0], // HTML5 video element video: document.getElementsByTagName('video')[0],
subUrl: newSubUrl, // Link to subtitles subUrl: newSubUrl,
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js" workerUrl: '/build/js/subtitles-octopus-worker.js',
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
fonts: [subFont], fonts: [subFont],
renderMode: 'wasm-blend', renderMode: 'wasm-blend',
}; };
@@ -191,215 +100,154 @@ function initSubtitles(lang) {
subtitleInstance = new SubtitlesOctopus(options); subtitleInstance = new SubtitlesOctopus(options);
} }
function initPlayerQualityChange(data) {
if (dashSupported && !apiResponse.legacy) {
player.on('qualitychange', function () {
initDash(data);
});
initDash(data);
}
}
function initDash(data) {
var videoEl = document.querySelector('video');
var quality = player.quality;
data.forEach(function (el) {
if (el.mode === 'mpd' && el.size === quality) {
var dash = dashjs.MediaPlayer().create();
dash.initialize(videoEl, el.src, true);
window.dash = dash;
player.dash = dash;
}
});
}
function initPlayer() { function initPlayer() {
player = new Plyr('#player', { var videoEl = document.querySelector('#player');
controls, var container = videoEl.parentElement;
quality: {
default: 720, var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
options: [2161, 2160, 1081, 1080, 720] var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
player = new HStreamPlayer({
container: container,
video: videoEl,
apiResponse: apiResponse,
streamServer: streamServer,
streamServers: streamServers,
fallbackServers: fallbackServers,
streamServerIndex: streamServerIndex,
av1Supported: av1Supported,
dashSupported: dashSupported,
poster: apiResponse.poster,
title: apiResponse.title,
data: data,
subtitleTracks: subtitleTracks,
volume: volume,
muted: muted,
captionsActive: captions,
captionLanguage: 'en',
ambientMode: ambientMode,
isMobile: isMobile(),
quality: parseInt(localStorage.getItem('hstreamQuality')) || 1080,
lastTime: lastTime,
subtitleInstance: subtitleInstance,
onEnded: function () {
playNextPlaylistVideo();
}, },
i18n: { onTimeUpdate: function () {
qualityLabel: { trackWatchTime();
2161: "2160p48", },
2160: "2160p", onQualityChange: function (size) {
1081: "1080p48", if (dashSupported && !apiResponse.legacy) {
1080: "1080p", initDash(data);
720: "720p" }
}, },
qualityBadge: { onVolumeChange: function () {
2161: "UHD@48", localStorage.setItem('hstreamVolume', player.volume.toString());
1081: "FHD@48", localStorage.setItem('hstreamMuted', player.muted.toString());
1080: "FHD", },
}, onCaptionsToggle: function (active) {
localStorage.setItem('hstreamCaptions', active.toString());
if (subtitleInstance && subtitleInstance.canvas) {
subtitleInstance.canvas.style.visibility = active ? 'visible' : 'hidden';
}
var libassParent = document.querySelector('.libassjs-canvas-parent');
if (libassParent) {
libassParent.style.visibility = active ? 'visible' : 'hidden';
}
},
onLanguageChange: function (lang) {
initSubtitles(lang);
if (player) {
player.setSubtitleInstance(subtitleInstance);
}
},
onServerChange: function (index) {
streamServerIndex = index;
var allServers = streamServers.concat(fallbackServers);
streamServer = allServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
clearInterval(saveInterval);
stopEngagementTracking();
player.destroy();
}
initPlayer();
}, },
fullscreen: { enabled: true, fallback: true, iosNative: true }
}); });
// Player Track Data window.player = player;
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
player.source = { if (player.captionsActive) {
type: 'video', initSubtitles(player.captionLanguage);
title: apiResponse.title, player.setSubtitleInstance(subtitleInstance);
poster: apiResponse.poster, }
previewThumbnails: {
enabled: true,
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
},
sources: data,
tracks: addSubtitleTracks(streamServer, apiResponse)
};
player.volume = volume; if (!isMobile()) {
player.muted = muted; player.initThumbnails(vttThumbsUrl);
//player.captions.languages = ['en']; }
player.captions.language = 'en';
player.captions.active = captions;
if (dashSupported && !apiResponse.legacy) { if (dashSupported && !apiResponse.legacy) {
player.on('qualitychange', () => { initDash(data);
initDash(data, player);
});
initDash(data, player);
} }
// Ambient Mode initMobileWidescreen(container, videoEl);
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0]; initMobileDoubleTap(container, videoEl, player);
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
var allItems = document.getElementsByClassName('plyr__control--forward'); var episodeId = document.getElementById('e_id').value;
var lastItem = allItems[allItems.length - 1]; player.initHeatmap(episodeId);
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
if (localStorage.ambientMode == 'false') { videoEl.addEventListener('play', function onFirstPlay() {
toggleAmbientMode(); videoEl.removeEventListener('play', onFirstPlay);
} startEngagementTracking(episodeId);
// Server select (Asia)
lastItem = allItems[allItems.length - 1];
let value = 'Off';
if (serverFallback) { value = 'On'; }
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
var clickedPlay = false;
player.on('play', () => {
if (!clickedPlay) {
player.stop();
console.log("Stopped video, because user didn't click play.")
}
setCanvasDimension(canvas, video);
console.log('Play => Function Loop()');
var $this = video;
(function loop() {
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
setTimeout(loop, 24000 / 1001); // drawing at 30fps
}
})();
}); });
player.on('seeked', () => {
paintStaticVideo(ctx, video);
if (player.currentTime > 0) {
lastTime = player.currentTime;
}
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
});
window.addEventListener("resize", () => {
setCanvasDimension(canvas, video);
if (player.paused) {
paintStaticVideo(ctx, video);
}
});
player.on('captionsenabled', () => {
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
localStorage.setItem('hstreamCaptions', 'true');
console.log('Set Captions Status to Local Storage: true');
});
player.on('captionsdisabled', () => {
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
localStorage.setItem('hstreamCaptions', 'false');
console.log('Set Captions Status to Local Storage: false');
});
player.on('volumechange', () => {
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
localStorage.setItem('hstreamVolume', player.volume.toString())
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
localStorage.setItem('hstreamMuted', player.muted.toString())
});
player.on('ended', () => {
playNextPlaylistVideo();
});
player.on('languagechange', (event) => {
let lang = event.detail.plyr.captions.language;
console.log('Subtitle Event ' + lang);
initSubtitles(lang);
});
function playerPlayTemp() {
clickedPlay = true;
}
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
play.addEventListener('click', playerPlayTemp)
);
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
initMobileWidescreen();
// Start time
setTimeout(function () { setTimeout(function () {
const params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
const time = parseInt(params.get("t")); var time = parseInt(params.get('t'));
if (!isNaN(time)) { if (!isNaN(time)) {
player.currentTime = time; player.currentTime = time;
console.log("Skipping to " + time) console.log('Skipping to ' + time);
} }
if (lastTime > 0) { if (lastTime > 0) {
player.currentTime = lastTime; player.currentTime = lastTime;
console.log("Skipping to " + lastTime) console.log('Skipping to ' + lastTime);
} }
}, 500); }, 500);
player.on('ready', () => {
mobileDoubleClick(player);
});
// Server Select
// I hate this...
var settingElements = document.getElementsByClassName('plyr__control--forward');
if (settingElements.length == 3) {
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
if (settingNodes.length == 4) {
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
}
// Event Listeners
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
let serverSelects = document.getElementsByClassName('change_server');
for (let i = 0; i < serverSelects.length; i++) {
serverSelects[i].addEventListener('click', function() {
streamServerIndex = Number(this.value);
streamServer = streamServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
clearInterval(saveInterval);
player.destroy();
}
initPlayer();
});
}
}
// Periodically save last timestamp
saveInterval = setInterval(function () { saveInterval = setInterval(function () {
lastTime = player.currentTime; lastTime = player.currentTime;
console.log("Last Player Position: " + lastTime);
}, 10000); }, 10000);
} }
async function initVidstackPlayer() { async function initVidstackPlayer() {
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4'; var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt'; var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt'; var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
player = await VidstackPlayer.create({ player = await VidstackPlayer.create({
target: '#player', target: '#player',
@@ -421,52 +269,56 @@ async function initVidstackPlayer() {
] ]
}); });
// Ambient Mode window.player = player;
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
player.addEventListener('play', () => { player.addEventListener('time-update', function () {
setCanvasDimension(canvas, video); trackWatchTime();
console.log('Play => Function Loop()');
var $this = video;
(function loop() {
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
setTimeout(loop, 24000 / 1001); // drawing at 30fps
}
})();
}); });
} }
// Get Data from API window.setPlayerPreference = function(pref) {
window.axios.post('/player/api', { localStorage.setItem('hstreamPlayerPreference', pref);
episode_id: document.getElementById('e_id').value window.location.reload();
}).then(function (response) { };
if (response.status == 200) {
apiResponse = response.data;
streamServers = apiResponse.stream_domains;
if (serverFallback) { const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
streamServers = apiResponse.asia_stream_domains;
if (playerPreference === 'plyr' && !isIOS()) {
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
} else {
window.axios.post('/player/api', {
episode_id: document.getElementById('e_id').value
}).then(function (response) {
if (response.status === 200) {
apiResponse = response.data;
streamServers = apiResponse.stream_domains || [];
fallbackServers = apiResponse.asia_stream_domains || [];
const cdnCount = streamServers.length;
if (cdnCount > 0) {
streamServerIndex = Math.floor(Math.random() * cdnCount);
streamServer = streamServers[streamServerIndex];
} else {
const fallbackCount = fallbackServers.length;
streamServerIndex = Math.floor(Math.random() * fallbackCount);
streamServer = fallbackServers[streamServerIndex];
}
streamServerCount = streamServers.length + fallbackServers.length;
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
if (!isIOS()) {
initPlayer();
} else {
console.log('Detected Apple device. Using Vidstack fallback player.');
initVidstackPlayer();
}
} }
}).catch(function (error) {
streamServerCount = streamServers.length; var alert = document.getElementById('player-alert');
streamServerIndex = Math.floor(Math.random() * streamServerCount); if (alert) {
streamServer = streamServers[streamServerIndex]; alert.innerText = 'The player encountered a problem: ' + error;
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex); alert.classList.remove('hidden');
if (!isIOS()) {
initPlayer();
} }
else { });
console.log("Detected Apple Shit. Using different player.") }
initVidstackPlayer();
}
}
}).catch(function (error) {
var alert = document.getElementById("player-alert");
alert.innerText = 'The player encountered a problem: ' + error;
alert.classList.remove("hidden");
});
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
if (dashSupported) {
return addDashTracks(streamServer, apiResponse, av1Supported);
}
return addLegacyTracks(streamServer, apiResponse, av1Supported);
}
function addDashTracks(streamServer, apiResponse, av1Supported) {
var data = [];
// 720p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
size: 720,
mode: 'mpd',
});
if (av1Supported) {
// 1080p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
size: 1080,
mode: 'mpd',
});
// 2160p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
size: 2160,
mode: 'mpd',
});
if (apiResponse.interpolated == 1) {
// 1080p Interpolated
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
size: 1081,
mode: 'mpd',
});
}
if (apiResponse.interpolated_uhd == 1) {
// 2160p Interpolated
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
size: 2161,
mode: 'mpd',
});
}
}
return data;
}
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
var data = [];
// 720p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
type: 'video/mp4',
size: 720,
});
return data;
}
export function addSubtitleTracks(streamServer, apiResponse) {
var data = [];
// Default
data.push({
kind: 'captions',
label: 'English',
srclang: 'en',
src: '',
default: true,
});
for (var key in apiResponse.extra_subtitles) {
data.push({
kind: 'captions',
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
srclang: key,
src: '',
default: false,
});
}
return data;
}
+72
View File
@@ -0,0 +1,72 @@
// Engagement heatmap tracking
// Samples the user's current time while playing and sends batched segment data to the server.
// Only tracks segment >= 1 (excludes 0-10s).
// Only calls the endpoint when the user is logged in.
let engagementInterval;
let engagementSegments = new Set();
let engagementReportInterval;
const SEGMENT_DURATION = 10; // seconds per segment
const SAMPLE_INTERVAL = 5000; // sample every 5s
const REPORT_INTERVAL = 15000; // send batch every 15s
function isAuthenticated() {
const el = document.getElementById('auth_check');
return el && el.value === '1';
}
function sendEngagement(episodeId, segments) {
if (!isAuthenticated()) return;
window.axios.post('/player/engagement', {
episode_id: episodeId,
segments: segments,
}).catch(() => {
// Fire-and-forget: silently ignore network errors
});
}
export function startEngagementTracking(episodeId) {
engagementSegments.clear();
// Sample current time while playing
engagementInterval = setInterval(() => {
const video = document.querySelector('video');
if (!video || video.paused) return;
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
// Skip segment 0 (0-10s) — no need to track the very start
if (segment >= 1) {
engagementSegments.add(segment);
}
}, SAMPLE_INTERVAL);
// Batch report to server
engagementReportInterval = setInterval(() => {
if (engagementSegments.size === 0) return;
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}, REPORT_INTERVAL);
// Flush remaining segments & cleanup on page unload
const cleanup = () => {
clearInterval(engagementInterval);
clearInterval(engagementReportInterval);
if (engagementSegments.size > 0) {
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}
};
window.addEventListener('beforeunload', cleanup);
}
export function stopEngagementTracking() {
if (engagementInterval) clearInterval(engagementInterval);
if (engagementReportInterval) clearInterval(engagementReportInterval);
}
+214
View File
@@ -0,0 +1,214 @@
// Engagement heatmap display
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
let heatmapContainer = null;
let heatmapCanvas = null;
let heatmapResizeObserver = null;
/**
* Fetch engagement data from the server and render the heatmap.
* @param {string} episodeId - The episode ID.
* @param {number} duration - Video duration in seconds.
*/
export async function renderHeatmap(episodeId, duration) {
try {
const response = await window.axios.get(`/player/engagement/${episodeId}`);
const data = response.data;
if (!data || Object.keys(data).length === 0) {
return;
}
drawHeatmapCurve(data, duration);
} catch (error) {
console.error('Failed to load engagement data:', error);
}
}
/**
* Draw a smooth area chart on a canvas element above the progress bar.
* @param {Object} data - Key-value map of segment -> watch_count.
* @param {number} duration - Video duration in seconds.
*/
function drawHeatmapCurve(data, duration) {
const SEGMENT_DURATION = 10;
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
// Build raw counts array, filling gaps with 0
const rawCounts = [];
for (let i = 0; i < totalSegments; i++) {
rawCounts.push(data[i] || 0);
}
// Apply weighted moving average to smooth individual spikes
const counts = smoothData(rawCounts);
const maxCount = Math.max(...counts, 1);
// Remove existing heatmap if present
if (heatmapContainer) {
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
heatmapContainer.remove();
heatmapCanvas = null;
}
const progressBar = document.querySelector('.hstream-player__progress');
if (!progressBar) return;
heatmapContainer = document.createElement('div');
heatmapContainer.className = 'hstream-player__progress-heatmap';
heatmapContainer.setAttribute('aria-hidden', 'true');
heatmapCanvas = document.createElement('canvas');
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
heatmapContainer.appendChild(heatmapCanvas);
// Insert as first child of the progress bar so it sits behind the scrubber
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
// Defer drawing to get container dimensions
requestAnimationFrame(() => {
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
});
// Redraw on resize
heatmapResizeObserver = new ResizeObserver(() => {
drawCurve(heatmapCanvas, counts, maxCount);
});
heatmapResizeObserver.observe(heatmapContainer);
}
/**
* Apply two passes of a 5-point weighted (Gaussian) moving average.
* Near edges falls back to a 3-point average.
* Preserves the first and last points.
*/
function smoothData(data) {
if (data.length <= 2) return [...data];
let result = data;
for (let pass = 0; pass < 2; pass++) {
const smoothed = [result[0]];
for (let i = 1; i < result.length - 1; i++) {
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
smoothed.push(
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
result[i + 1] * 2 + result[i + 2] * 1) / 10
);
} else {
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
}
}
smoothed.push(result[result.length - 1]);
result = smoothed;
}
return result;
}
/**
* Render vertical bar heatmap directly on the progress bar track.
* Each bar represents a time segment; taller bars = more engagement.
*/
function drawCurve(canvas, counts, maxCount) {
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = rect.width;
const h = rect.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (counts.length === 0 || maxCount === 0) return;
const paddingX = 1;
const paddingY = 2;
const drawW = w - paddingX * 2;
const drawH = h - paddingY * 2;
const n = counts.length;
const pts = [];
for (let i = 0; i < n; i++) {
const x = paddingX + (i / (n - 1 || 1)) * drawW;
const ratio = Math.min(counts[i] / maxCount, 1);
const y = paddingY + (1 - ratio) * drawH;
pts.push({ x, y });
}
if (pts.length < 2) return;
// Build the smooth path using quadratic bezier curves through midpoints
const path = [{ x: pts[0].x, y: pts[0].y }];
for (let i = 0; i < pts.length - 1; i++) {
const midX = (pts[i].x + pts[i + 1].x) / 2;
const midY = (pts[i].y + pts[i + 1].y) / 2;
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
}
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
// --- Draw a subtle glow behind the line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 3.0;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// --- Draw the main waveform line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
/**
* Remove the heatmap from the DOM.
*/
export function removeHeatmap() {
if (heatmapResizeObserver) {
heatmapResizeObserver.disconnect();
heatmapResizeObserver = null;
}
if (heatmapContainer) {
heatmapContainer.remove();
heatmapContainer = null;
heatmapCanvas = null;
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Mobile-specific player features:
* - Double-tap left/right to skip ±10s
* - Object-fit toggle button for widescreen fill
*/
export function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
export function initMobileWidescreen(playerWrapper, video) {
if (!isMobile()) {
return;
}
const controls = playerWrapper.querySelector('.hstream-player__controls');
if (!controls) {
return;
}
const btn = document.createElement('button');
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle screen fill');
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
btn.title = 'Fill Screen';
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
if (fullscreenBtn) {
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
} else {
controls.appendChild(btn);
}
let fillEnabled = true;
video.style.objectFit = 'cover';
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (fillEnabled) {
video.style.objectFit = 'contain';
fillEnabled = false;
btn.classList.remove('hstream-player__button--active');
} else {
video.style.objectFit = 'cover';
fillEnabled = true;
btn.classList.add('hstream-player__button--active');
}
});
btn.classList.add('hstream-player__button--active');
}
export function initMobileDoubleTap(playerWrapper, video, player) {
if (!isMobile()) {
return;
}
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
if (!skipOverlay) {
return;
}
class MultiClickCounter {
constructor() {
this.timers = [];
this.count = 0;
this.reseted = 0;
this.lastSide = null;
}
clicked() {
this.count += 1;
const xcount = this.count;
this.timers.push(setTimeout(() => this.reset(xcount), 500));
return this.count;
}
resetCount(n) {
this.reseted = this.count;
this.count = n;
this.timers.forEach(t => clearTimeout(t));
this.timers = [];
}
reset(xcount) {
if (this.count > xcount) return;
this.count = 0;
this.lastSide = null;
this.reseted = 0;
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
this.timers = [];
}
}
const counter = new MultiClickCounter();
const handleTap = (e) => {
e.preventDefault();
const count = counter.clicked();
if (count < 2) return;
const rect = e.target.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const perc = (x / rect.width) * 100;
let shouldReset = true;
const lastSide = counter.lastSide;
if (lastSide === null) {
shouldReset = false;
}
if (perc < 40) {
if (player.currentTime === 0) return;
counter.lastSide = 'L';
if (shouldReset && lastSide !== 'L') {
counter.resetCount(1);
return;
}
const skipSeconds = (count - 1) * 10;
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
} else if (perc > 60) {
if (player.currentTime >= player.duration) return;
counter.lastSide = 'R';
if (shouldReset && lastSide !== 'R') {
counter.resetCount(1);
return;
}
const skipSeconds = (count - 1) * 10;
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
} else {
player.togglePlay();
counter.lastSide = 'C';
}
};
playerWrapper.addEventListener('click', handleTap);
video.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
@@ -0,0 +1,69 @@
/**
* Builds the server/CDN selector submenu panel for the settings menu.
* @param {string[]} streamServers - Regular CDN server URLs
* @param {string[]} fallbackServers - Fallback server URLs
* @param {number} selectedIndex - Index in the combined server list
* @param {function} onSelect - Callback receiving the combined index
*/
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
const panel = document.createElement('div');
panel.className = 'hstream-player__menu-panel';
panel.setAttribute('data-panel', 'server');
const backBtn = document.createElement('button');
backBtn.className = 'hstream-player__menu-back';
backBtn.type = 'button';
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
backBtn.addEventListener('click', (e) => {
e.stopPropagation();
const menuContainer = panel.closest('.hstream-player__menu-container');
if (menuContainer) {
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
}
});
panel.appendChild(backBtn);
const addServerItems = (servers, labelPrefix, startIndex) => {
for (let i = 0; i < servers.length; i++) {
const index = startIndex + i;
const item = document.createElement('button');
item.className = 'hstream-player__menu-item';
item.type = 'button';
item.setAttribute('role', 'menuitemradio');
if (index === selectedIndex) {
item.classList.add('hstream-player__menu-item--checked');
item.setAttribute('aria-checked', 'true');
} else {
item.setAttribute('aria-checked', 'false');
}
const num = i + 1;
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
item.addEventListener('click', (e) => {
e.stopPropagation();
onSelect(index);
});
panel.appendChild(item);
}
};
const divider = document.createElement('div');
divider.className = 'hstream-player__menu-divider';
panel.appendChild(divider);
addServerItems(streamServers, 'Server', 0);
if (fallbackServers && fallbackServers.length > 0) {
const fbDivider = document.createElement('div');
fbDivider.className = 'hstream-player__menu-divider';
panel.appendChild(fbDivider);
addServerItems(fallbackServers, 'Fallback', streamServers.length);
}
return panel;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* VTT-based sprite thumbnail preview.
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
* a floating preview image above the progress bar on hover.
*/
export class ThumbnailPreview {
constructor(progressWrapper, video) {
this.progressWrapper = progressWrapper;
this.video = video;
this.cues = [];
this.spriteImg = null;
this.thumbnailWidth = 160;
this.thumbnailHeight = 90;
this.visible = false;
this.el = document.createElement('div');
this.el.className = 'hstream-player__thumbnail-preview';
this.el.setAttribute('aria-hidden', 'true');
this.imgEl = document.createElement('div');
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
this.el.appendChild(this.imgEl);
this.timeEl = document.createElement('div');
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
this.el.appendChild(this.timeEl);
this.el.style.display = 'none';
this.progressWrapper.appendChild(this.el);
this._onMove = this._onMove.bind(this);
this._onLeave = this._onLeave.bind(this);
}
/**
* Fetch and parse the thumbnail VTT file.
* @param {string} vttUrl
*/
async load(vttUrl) {
try {
const response = await fetch(vttUrl);
if (!response.ok) {
throw new Error('Failed to fetch VTT: ' + response.status);
}
const text = await response.text();
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
this.cues = this._parseVTT(text, baseDir);
if (this.cues.length > 0) {
this.spriteImg = new Image();
this.spriteImg.crossOrigin = 'anonymous';
this.spriteImg.src = this.cues[0].spriteUrl;
await new Promise((resolve, reject) => {
this.spriteImg.onload = resolve;
this.spriteImg.onerror = reject;
});
}
this._attach();
} catch (err) {
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
}
}
/**
* Parse WEBVTT text extracting cues with sprite coordinates.
*/
_parseVTT(text, baseDir) {
const cues = [];
const lines = text.split(/\r?\n/);
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
const resolveUrl = (maybeRelative) => {
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
return maybeRelative;
}
try {
return new URL(maybeRelative, baseDir).href;
} catch (e) {
return baseDir + maybeRelative;
}
};
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
const match = line.match(cueRegex);
if (match) {
const startTime = this._timeToSeconds(match[1]);
const endTime = this._timeToSeconds(match[2]);
i++;
while (i < lines.length) {
const payload = lines[i].trim();
if (payload === '' || payload.match(cueRegex)) {
break;
}
const xywh = payload.match(xywhRegex);
if (xywh) {
const rawUrl = payload.substring(0, xywh.index);
cues.push({
startTime,
endTime,
spriteUrl: resolveUrl(rawUrl),
x: parseInt(xywh[1], 10),
y: parseInt(xywh[2], 10),
w: parseInt(xywh[3], 10),
h: parseInt(xywh[4], 10),
});
break;
}
const noteMatch = payload.match(/^NOTE/);
if (!noteMatch) {
const urlMatch = payload.match(/^(\S+)/);
if (urlMatch) {
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
break;
}
}
i++;
}
}
i++;
}
return cues;
}
_timeToSeconds(timestamp) {
const [h, m, s] = timestamp.split(':');
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
}
_attach() {
this.progressWrapper.addEventListener('mousemove', this._onMove);
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
this.progressWrapper.addEventListener('touchend', this._onLeave);
}
_onMove(e) {
const rect = this.progressWrapper.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const ratio = Math.max(0, Math.min(1, x / rect.width));
const time = ratio * this.video.duration;
const cue = this._findCue(time);
if (!cue) {
this._hide();
return;
}
this._show(cue, time, rect, x);
}
_findCue(time) {
for (let i = 0; i < this.cues.length; i++) {
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
return this.cues[i];
}
}
return null;
}
_show(cue, time, progressRect, mouseX) {
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
this.imgEl.style.width = cue.w + 'px';
this.imgEl.style.height = cue.h + 'px';
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
const mins = Math.floor(time / 60);
const secs = Math.floor(time % 60);
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
const containerWidth = this.progressWrapper.offsetWidth;
const halfW = cue.w / 2;
let left = mouseX;
if (left < halfW + 4) left = halfW + 4;
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
this.el.style.left = left + 'px';
this.el.style.display = '';
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
if (timeTooltip) {
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
}
if (!this.visible) {
this.visible = true;
requestAnimationFrame(() => {
this.el.classList.add('hstream-player__thumbnail-preview--visible');
});
}
}
_hide() {
this.visible = false;
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
setTimeout(() => {
if (!this.visible) {
this.el.style.display = 'none';
}
}, 150);
}
_onLeave() {
this._hide();
}
destroy() {
this.progressWrapper.removeEventListener('mousemove', this._onMove);
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
this.progressWrapper.removeEventListener('touchmove', this._onMove);
this.progressWrapper.removeEventListener('touchend', this._onLeave);
if (this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
}
}
-68
View File
@@ -1,68 +0,0 @@
function updateGrid(grid) {
// Skip hidden grids
if (grid.offsetParent === null) {
return;
}
const items = [...grid.querySelectorAll('.episode-item')];
if (!items.length) {
return;
}
// Reset visibility first
items.forEach(item => {
item.style.display = '';
});
// Determine actual column count
const firstTop = items[0].offsetTop;
let columns = 0;
for (const item of items) {
if (item.offsetTop !== firstTop) {
break;
}
columns++;
}
const rows = parseInt(grid.dataset.rows || '2', 10);
const visibleItems = columns * rows;
items.forEach((item, index) => {
item.style.display = index < visibleItems
? ''
: 'none';
});
}
function updateAllEpisodeGrids() {
document.querySelectorAll('.episode-grid').forEach(updateGrid);
}
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
updateGrid(entry.target);
}
});
});
document.querySelectorAll('.episode-grid').forEach(grid => {
observer.observe(grid);
});
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
updateAllEpisodeGrids();
}, 100);
});
window.addEventListener('load', updateAllEpisodeGrids);
+276 -36
View File
@@ -1,73 +1,313 @@
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
// Theming /**
if (localStorage.theme !== 'light') { * Theme-aware chart defaults
Chart.defaults.color = "#ADBABD"; */
Chart.defaults.borderColor = "rgba(255,255,255,0.1)"; function getChartColors() {
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)"; const isDark = localStorage.theme !== 'light' &&
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)"; (!('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',
};
}
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',
};
} }
// Get Tags from API /**
window.axios.get('/v1/monthly-views').then(function (response) { * Show the skeleton loader
if (response.status != 200) { */
return; 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');
}
const data = { /**
labels: response.data.map((entry) => { return entry.date }), * 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: [{ datasets: [{
label: 'Views', label: 'Views',
fill: false, fill: true,
backgroundColor: 'rgba(190, 18, 60, 0.3)', backgroundColor: gradient,
borderColor: 'rgba(190, 18, 60, 1.0)', 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', cubicInterpolationMode: 'monotone',
data: response.data.map((entry) => { return entry.count }), tension: 0.4,
data: data.map((entry) => entry.count),
}] }]
} };
const config = { const config = {
type: 'line', type: 'line',
data: data, data: chartData,
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1200,
easing: 'easeOutQuart',
},
plugins: { plugins: {
title: { title: {
display: true, display: false,
text: 'Views the last 28 days', },
font: { legend: {
size: 18 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: { interaction: {
intersect: false, intersect: false,
mode: 'index',
}, },
scales: { scales: {
x: { x: {
display: true, display: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
maxTicksLimit: 14,
maxRotation: 0,
},
title: { title: {
display: true display: false,
} }
}, },
y: { y: {
display: true, display: true,
title: { beginAtZero: true,
display: true, grid: {
text: 'Views' 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( hideError();
document.getElementById('monthlyChart'), monthlyViewChart = new Chart(canvas, config);
config hideSkeleton();
); }
}).catch(function (error) {
console.log(error); /**
* 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);
}
});
@@ -1,21 +1,41 @@
<div data-te-modal-init class="fixed left-0 top-0 z-[1055] hidden h-full w-full overflow-y-auto overflow-x-hidden outline-none" id="modalEditEpisode" tabindex="-1" aria-labelledby="Upload" aria-modal="true" role="dialog"> <div
<div data-te-modal-dialog-ref class="pointer-events-none relative flex min-h-[calc(100%-1rem)] w-auto translate-y-[-50px] items-center opacity-0 transition-all duration-300 ease-in-out min-[576px]:mx-auto min-[576px]:mt-7 min-[576px]:min-h-[calc(100%-3.5rem)] min-[576px]:max-w-[95%] md:min-[576px]:max-w-[90%] lg:min-[576px]:max-w-[80%] xl:min-[576px]:max-w-[70%] 2xl:min-[576px]:max-w-[50%]"> data-te-modal-init
<div class="flex relative flex-col w-full text-current bg-clip-padding bg-white rounded-md border-none shadow-lg outline-none pointer-events-auto dark:bg-neutral-800"> id="modalEditEpisode"
tabindex="-1"
aria-modal="true"
role="dialog"
class="fixed inset-0 z-[1055] hidden overflow-y-auto bg-black/60 backdrop-blur-sm"
>
<div data-te-modal-dialog-ref class="flex min-h-screen items-center justify-center p-4">
<div class="relative w-full max-w-7xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
<x-modal-header :title="__('Edit Episode')"/> <x-modal-header :title="__('Edit Episode')"/>
<!--Modal body--> <!--Modal body-->
<div class="relative p-4 pt-0"> <div class="relative p-4 pt-0">
<form method="POST" action="{{ route('admin.edit') }}" enctype="multipart/form-data"> <form method="POST" action="{{ route('admin.episode.edit') }}" enctype="multipart/form-data">
@csrf @csrf
<div class="grid grid-cols-3"> <div class="flex flex-col gap-2 p-2">
<div class="col-span-2"> <div>
<!-- Tags --> <label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title">Title:</label>
<div class="row-span-2 p-0"> <x-text-input id="title" value="{{ $episode->title }}" class="block w-full" type="text" name="title" required autofocus/>
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="tags">Tags:</label>
<x-text-input id="tags" class="block w-full" type="text" name="tags" required />
</div>
</div> </div>
<div>
<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" value="{{ $episode->title_jpn }}" class="block w-full" type="text" name="title_jpn" required />
</div>
</div>
<div class="grid grid-cols-3 p-2">
<div class="col-span-2">
<!-- Tags -->
<div class="row-span-2 p-0">
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="tags">Tags:</label>
<x-text-input id="tags" class="block w-full" type="text" name="tags" required />
</div>
</div>
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<div class="grid grid-rows-2"> <div class="grid grid-rows-2">
<!-- Studio --> <!-- Studio -->
<div class="p-2 pt-0"> <div class="p-2 pt-0">
@@ -47,13 +67,16 @@
</div> </div>
</div> </div>
</div> </div>
@endif
</div> </div>
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<!-- Stream URL --> <!-- Stream URL -->
<div class="p-2 pt-0"> <div class="p-2 pt-0">
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="baseurl">Stream:</label> <label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="baseurl">Stream:</label>
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" value="{{ $episode->url }}" required /> <x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" value="{{ $episode->url }}" required />
</div> </div>
@endif
<input name="episode_id" id="episode_id" type="hidden" value="{{ $episode->id }}" /> <input name="episode_id" id="episode_id" type="hidden" value="{{ $episode->id }}" />
@@ -62,6 +85,7 @@
<textarea rows="4" cols="50" id="description" name="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm 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" required>{{ $episode->description }}</textarea> <textarea rows="4" cols="50" id="description" name="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm 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" required>{{ $episode->description }}</textarea>
</div> </div>
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<!-- Episodes --> <!-- Episodes -->
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
<!-- Cover --> <!-- Cover -->
@@ -95,8 +119,10 @@
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="downloadUHDi1">Download 4k Interpolated:</label> <label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="downloadUHDi1">Download 4k Interpolated:</label>
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" value="{{ $episode->getDownloadByType('UHDi')->url ?? '' }}" /> <x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" value="{{ $episode->getDownloadByType('UHDi')->url ?? '' }}" />
</div> </div>
@endif
<div class="flex flex-wrap flex-shrink-0 justify-end items-center p-4 rounded-b-md"> <div class="sticky bottom-0 flex items-center justify-end gap-3 border-t border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<div class="inline-block mr-2"> <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" <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="v2" name="v2" /> type="checkbox" value="true" id="v2" name="v2" />
@@ -111,10 +137,17 @@
DMCA Takedown DMCA Takedown
</label> </label>
</div> </div>
<button type="button" class="inline-block px-6 pt-2.5 pb-2 text-xs font-medium leading-normal uppercase rounded transition duration-150 ease-in-out bg-primary-100 text-primary-700 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"> @endif
<button
type="button"
data-te-modal-dismiss
class="rounded-xl border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
Cancel Cancel
</button> </button>
<button type="submit" class="inline-block px-6 pt-2.5 pb-2 ml-1 text-xs font-medium leading-normal text-white uppercase bg-rose-600 rounded transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light"> <button
type="submit"
data-te-ripple-init
class="rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700">
Edit Edit
</button> </button>
</div> </div>
-23
View File
@@ -1,23 +0,0 @@
@auth
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR))
<div class="relative p-5 bg-white dark:bg-neutral-700/40 rounded-lg overflow-hidden z-10">
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<div class="float-left">
<a data-te-toggle="modal" data-te-target="#modalUploadEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
<i class="fa-solid fa-plus pr-[6px]"></i> Add Episode
</a>
</div>
@endif
<div class="float-right">
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<a data-te-toggle="modal" data-te-target="#modalAddSubtitles" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
<i class="fa-solid fa-plus pr-[6px]"></i> Add Subtitles
</a>
@endif
<a data-te-toggle="modal" data-te-target="#modalEditEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
<i class="fa-solid fa-pen pr-[6px]"></i> Edit Episode
</a>
</div>
</div>
@endif
@endauth
+8
View File
@@ -28,6 +28,14 @@
</div> </div>
</a> </a>
@if (session('error'))
<div class="mb-4 rounded-md bg-red-200 p-4 border border-red-200">
<div class="text-sm text-red-700">
{{ session('error') }}
</div>
</div>
@endif
<!-- Or --> <!-- Or -->
<div class="grid grid-cols-3"> <div class="grid grid-cols-3">
<hr class="self-center border-neutral-600"> <hr class="self-center border-neutral-600">
@@ -24,7 +24,7 @@
<div class="group w-full p-1"> <div class="group w-full p-1">
<a <a
href="{{ route('hentai.index', ['title' => $episode->slug]) }}" href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
class="block overflow-hidden rounded-2xl border dark:border-neutral-800 border-neutral-300 dark:bg-neutral-900 transition-all duration-300 hover:-translate-y-1 dark:hover:border-neutral-700 hover:border-neutral-400 hover:shadow-2xl hover:shadow-black/30" class="block overflow-hidden rounded-2xl border border-neutral-200 bg-white transition-all duration-300 hover:-translate-y-1 hover:border-neutral-400 hover:shadow-xl dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700"
> >
<div class="relative overflow-hidden"> <div class="relative overflow-hidden">
@@ -35,7 +35,7 @@
alt="{{ $episode->title }} - {{ $episode->episode }}" alt="{{ $episode->title }} - {{ $episode->episode }}"
loading="lazy" loading="lazy"
width="400" width="400"
class="aspect-[11/16] w-full object-cover object-center transform-gpu transition-transform duration-500 group-hover:scale-[1.02]" class="aspect-[11/16] w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
> >
@elseif ($view === 'thumbnail') @elseif ($view === 'thumbnail')
@php @php
@@ -50,49 +50,56 @@
loading="lazy" loading="lazy"
width="1000" width="1000"
data-gallery='@json($galleryImages)' data-gallery='@json($galleryImages)'
class="preview-gallery aspect-video w-full object-cover object-center transform-gpu transition-transform duration-500 group-hover:scale-[1.02]" class="preview-gallery aspect-video w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
> >
@endif @endif
{{-- Overlay Gradient --}} {{-- Dark Overlay --}}
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent"></div> <div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent"></div>
{{-- Top Row --}} {{-- Top Meta --}}
<div class="absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3"> <div class="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3">
{{-- Problematic Tags --}} {{-- Problematic Tags --}}
@if (!empty($problematic)) @if (!empty($problematic))
<div class="rounded-xl border border-red-500/30 bg-red-900/70 px-2.5 py-1 text-xs font-semibold text-white"> <div class="rounded-full bg-red-700/40 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-white ring-1 ring-red-700/70">
<i class="fa-solid fa-triangle-exclamation mr-1"></i> <i class="fa-solid fa-triangle-exclamation mr-1"></i>
{{ $problematic }} {{ $problematic }}
</div> </div>
@endif @endif
{{-- Resolution --}} {{-- Resolution --}}
<div class="ml-auto rounded-xl bg-black/70 px-2.5 py-1 text-xs font-semibold tracking-wide text-neutral-100 ring-1 ring-white/10"> <div class="ml-auto rounded-full bg-black/70 px-2 py-1 text-[11px] font-semibold tracking-wide text-white ring-1 ring-white/10">
{{ $episode->getResolution() }} {{ $episode->getResolution() }}
</div> </div>
</div> </div>
{{-- Bottom Stats --}} {{-- Bottom Content --}}
<div class="absolute inset-x-0 bottom-0 z-20 p-3"> <div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-4">
<div class="flex items-end justify-between gap-3">
{{-- Title --}}
<h3 class=" text-sm font-semibold leading-snug text-white md:text-base">
{{ $title }}
</h3>
{{-- Bottom Row --}}
<div class="mt-3 flex items-center justify-between gap-3">
{{-- Stats --}} {{-- Stats --}}
<div class="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm font-bold text-neutral-200"> <div class="flex flex-wrap items-center gap-3 text-sm font-bold text-neutral-200">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<i class="fa-regular fa-eye text-neutral-400"></i> <i class="fa-regular fa-eye text-neutral-200 font-bold"></i>
{{ $episode->viewCountFormatted() }} {{ $episode->viewCountFormatted() }}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<i class="fa-regular fa-heart text-neutral-400"></i> <i class="fa-regular fa-heart text-neutral-200 font-bold"></i>
{{ $episode->likeCount() }} {{ $episode->likeCount() }}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<i class="fa-regular fa-comment text-neutral-400"></i> <i class="fa-regular fa-comment text-neutral-200 font-bold"></i>
{{ $episode->commentCount() }} {{ $episode->commentCount() }}
</span> </span>
</div> </div>
@@ -102,9 +109,9 @@
@if ($isWatched) @if ($isWatched)
<div class="shrink-0 rounded-full bg-emerald-800/40 px-2.5 py-1 text-xs font-semibold text-emerald-300 ring-1 ring-emerald-500/30"> <div class="shrink-0 rounded-full bg-emerald-800/40 px-2.5 py-1 text-xs font-semibold text-emerald-300 ring-1 ring-emerald-500/30">
@if ($view === 'thumbnail') @if ($view === 'thumbnail')
<i class="fa-solid fa-check mr-1"></i> Watched <i class="fa-solid fa-eye mr-1"></i> Watched
@else @else
<i class="fa-solid fa-check"></i> <i class="fa-solid fa-eye"></i>
@endif @endif
</div> </div>
@else @else
@@ -116,13 +123,7 @@
</div> </div>
</div> </div>
</div>
{{-- Content --}}
<div class="relative isolate border-t dark:border-neutral-800 dark:bg-neutral-900 bg-white border-neutral-100 p-4">
<h3 class="text-sm font-semibold leading-relaxed dark:text-neutral-100 text-neutral-900 transition-colors duration-200 dark:group-hover:text-white">
{{ $title }}
</h3>
</div> </div>
</a> </a>
</div> </div>
@@ -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
View File
@@ -28,6 +28,4 @@
<div class="mx-auto pt-6 sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%] pb-2"> <div class="mx-auto pt-6 sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%] pb-2">
@include('home.partials.comments') @include('home.partials.comments')
</div> </div>
@vite(['resources/js/responsive.js'])
</x-app-layout> </x-app-layout>
@@ -55,10 +55,9 @@
> >
{{-- Resolution Badge --}} {{-- Resolution Badge --}}
<span <div class="absolute right-2 top-2 rounded-lg bg-black/70 px-2 py-1 text-[11px] font-semibold tracking-wide text-white ring-1 ring-white/10">
class="absolute right-0 top-0 rounded-bl-lg bg-rose-700/70 px-3 py-1 text-xs font-semibold text-white shadow-lg backdrop-blur">
{{ $resolution }} {{ $resolution }}
</span> </div>
{{-- Gradient Overlay --}} {{-- Gradient Overlay --}}
<div <div
@@ -4,10 +4,10 @@
@php @php
$random = \cache()->remember('random_home', 300, function () { $random = \cache()->remember('random_home', 300, function () {
return \App\Models\Episode::inRandomOrder()->limit(8)->get(); ; return \App\Models\Episode::inRandomOrder()->limit(16)->get(); ;
}); });
@endphp @endphp
<div class="mb-6"> <div class="mb-6">
@include('home.partials.tab.template', ['episodes' => $random, 'showThumbnails' => false]) @include('home.partials.tab.template', ['episodes' => $random, 'isThumbnail' => false])
</div> </div>
@@ -1,20 +1,34 @@
@props(['isThumbnail'])
@php @php
$isThumbnail = $showThumbnails;
$gridClasses = $isThumbnail
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6'
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
// Render enough items for largest possible layout // Render enough items for largest possible layout
$limit = 24; $limit = 16;
$view = $isThumbnail ? 'thumbnail' : 'poster'; $view = $isThumbnail ? 'thumbnail' : 'poster';
@endphp @endphp
@if ($isThumbnail)
<div <div
class="episode-grid grid {{ $gridClasses }}" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5
data-rows="2" [&>.episode-item]:hidden
[&>.episode-item:nth-child(-n+8)]:block
md:[&>.episode-item:nth-child(-n+8)]:block
lg:[&>.episode-item:nth-child(-n+9)]:block
xl:[&>.episode-item:nth-child(-n+9)]:block
2xl:[&>.episode-item:nth-child(-n+12)]:block
3xl:[&>.episode-item:nth-child(-n+15)]:block"
> >
@else
<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-8
[&>.episode-item]:hidden
[&>.episode-item:nth-child(-n+12)]:block
md:[&>.episode-item:nth-child(-n+12)]:block
xl:[&>.episode-item:nth-child(-n+12)]:block
2xl:[&>.episode-item:nth-child(-n+15)]:block
3xl:[&>.episode-item:nth-child(-n+16)]:block"
>
@endif
@foreach ($episodes->take($limit) as $ep) @foreach ($episodes->take($limit) as $ep)
@php @php
$episode = isset($popularView) $episode = isset($popularView)
@@ -22,7 +36,7 @@
: $ep; : $ep;
@endphp @endphp
<div class="episode-item"> <div class="episode-item p-1">
<x-episode-cover <x-episode-cover
:episode="$episode" :episode="$episode"
:view="$view" :view="$view"
@@ -34,11 +34,11 @@
</li> </li>
</ul> </ul>
@php $showThumbnails = true; @endphp @php $isThumbnail = true; @endphp
@auth @auth
@if (!Auth::user()->home_middle_design) @if (!Auth::user()->home_middle_design)
@php $showThumbnails = false; @endphp @php $isThumbnail = false; @endphp
@endif @endif
@endauth @endauth
@@ -48,7 +48,7 @@
id="tabs-most-views" role="tabpanel" aria-labelledby="tabs-most-views-tab" data-te-tab-active> id="tabs-most-views" role="tabpanel" aria-labelledby="tabs-most-views-tab" data-te-tab-active>
@include('home.partials.tab.template', [ @include('home.partials.tab.template', [
'episodes' => $popularAllTime, 'episodes' => $popularAllTime,
'showThumbnails' => $showThumbnails, 'isThumbnail' => $isThumbnail,
]) ])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
<a href="{{ route('hentai.search', ['order' => 'view-count']) }}" <a href="{{ route('hentai.search', ['order' => 'view-count']) }}"
@@ -61,7 +61,7 @@
id="tabs-most-likes" role="tabpanel" aria-labelledby="tabs-most-likes-tab"> id="tabs-most-likes" role="tabpanel" aria-labelledby="tabs-most-likes-tab">
@include('home.partials.tab.template', [ @include('home.partials.tab.template', [
'episodes' => $mostLikes, 'episodes' => $mostLikes,
'showThumbnails' => $showThumbnails, 'isThumbnail' => $isThumbnail,
]) ])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
<a href="{{ route('hentai.search', ['order' => 'view-count']) }}" <a href="{{ route('hentai.search', ['order' => 'view-count']) }}"
@@ -74,7 +74,7 @@
id="tabs-popular-weekly" role="tabpanel" aria-labelledby="tabs-popular-weekly-tab"> id="tabs-popular-weekly" role="tabpanel" aria-labelledby="tabs-popular-weekly-tab">
@include('home.partials.tab.template', [ @include('home.partials.tab.template', [
'episodes' => $popularWeekly, 'episodes' => $popularWeekly,
'showThumbnails' => $showThumbnails, 'isThumbnail' => $isThumbnail,
'popularView' => true, 'popularView' => true,
]) ])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
@@ -88,7 +88,7 @@
id="tabs-popular-monthly" role="tabpanel" aria-labelledby="tabs-popular-monthly-tab"> id="tabs-popular-monthly" role="tabpanel" aria-labelledby="tabs-popular-monthly-tab">
@include('home.partials.tab.template', [ @include('home.partials.tab.template', [
'episodes' => $popularMonthly, 'episodes' => $popularMonthly,
'showThumbnails' => $showThumbnails, 'isThumbnail' => $isThumbnail,
'popularView' => true, 'popularView' => true,
]) ])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
@@ -21,18 +21,18 @@
</ul> </ul>
@php $showThumbnails = false; @endphp @php $isThumbnail = false; @endphp
@auth @auth
@if(Auth::user()->home_top_design) @if(Auth::user()->home_top_design)
@php $showThumbnails = true; @endphp @php $isThumbnail = true; @endphp
@endif @endif
@endauth @endauth
<!--Tabs content--> <!--Tabs content-->
<div class="mb-6"> <div class="mb-6">
<div class="hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-recently-uploaded" role="tabpanel" aria-labelledby="tabs-recently-uploaded-tab" data-te-tab-active> <div class="hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-recently-uploaded" role="tabpanel" aria-labelledby="tabs-recently-uploaded-tab" data-te-tab-active>
@include('home.partials.tab.template', ['episodes' => $recentlyUploaded, 'showThumbnails' => $showThumbnails]) @include('home.partials.tab.template', ['episodes' => $recentlyUploaded, 'isThumbnail' => $isThumbnail])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded']) }}" <a href="{{ route('hentai.search', ['order' => 'recently-uploaded']) }}"
class="rounded bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600"> class="rounded bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
@@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-recently-released" role="tabpanel"aria-labelledby="tabs-recently-released-tab"> <div class="hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-recently-released" role="tabpanel"aria-labelledby="tabs-recently-released-tab">
@include('home.partials.tab.template', ['episodes' => $recentlyReleased, 'showThumbnails' => $showThumbnails]) @include('home.partials.tab.template', ['episodes' => $recentlyReleased, 'isThumbnail' => $isThumbnail])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
<a href="{{ route('hentai.search', ['order' => 'recently-released']) }}" <a href="{{ route('hentai.search', ['order' => 'recently-released']) }}"
class="rounded bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600"> class="rounded bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div class="hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-trending" role="tabpanel"aria-labelledby="tabs-trending-tab"> <div class="hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-trending" role="tabpanel"aria-labelledby="tabs-trending-tab">
@include('home.partials.tab.template', ['episodes' => $popularDaily, 'showThumbnails' => $showThumbnails, 'popularView' => true]) @include('home.partials.tab.template', ['episodes' => $popularDaily, 'isThumbnail' => $isThumbnail, 'popularView' => true])
<div class="grid text-center pt-5 "> <div class="grid text-center pt-5 ">
<a href="{{ route('hentai.search', ['order' => 'recently-released']) }}" <a href="{{ route('hentai.search', ['order' => 'recently-released']) }}"
class="rounded invisible bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600"> class="rounded invisible bg-rose-600 p-1 mr-2 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
+353 -66
View File
@@ -1,78 +1,365 @@
<x-app-layout> <x-app-layout>
<div class="container mx-auto px-4 py-12 md:py-24"> <div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
<section class="text-center mb-16"> {{-- Header Section --}}
<!-- Logo --> <section class="text-center mb-10 md:mb-14">
<div class="flex justify-center mb-8"> <div class="flex justify-center mb-6">
<img <img
src="/images/cropped-HS-1-270x270.webp" src="/images/cropped-HS-1-270x270.webp"
alt="hstream.moe Logo" 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> </div>
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
<!-- Stats Grid --> Site Statistics
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8"> </h1>
<!-- View Count Card --> <p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300"> A comprehensive overview of hstream.moe's content and community activity
<div class="flex justify-center mb-4"> </p>
<i class="fa-solid fa-eye text-4xl text-sky-600 dark:text-sky-400 p-3"></i> <div class="mt-5 flex justify-center">
</div> <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">
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <span class="relative flex h-2 w-2">
{{ number_format($viewCount) }} <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
</div> <span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300"> </span>
total views Live data · Updated hourly
</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>
</div> </div>
</div> </div>
</section> </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> </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']) @vite(['resources/js/stats.js'])
</x-app-layout> </x-app-layout>
@@ -1,60 +1,291 @@
<div> <div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage"> x-data="{
<div class="flex items-center justify-center"> showConfirm: false,
<div class="relative overflow-x-auto rounded-lg w-3/6"> confirmTitle: '',
confirmMessage: '',
confirmButtonClass: '',
confirmCallback: null,
openConfirm(title, message, buttonClass, callback) {
this.confirmTitle = title;
this.confirmMessage = message;
this.confirmButtonClass = buttonClass;
this.confirmCallback = callback;
this.showConfirm = true;
},
executeConfirm() {
if (this.confirmCallback) this.confirmCallback();
this.showConfirm = false;
},
expandedComment: null,
toggleExpand(id) {
this.expandedComment = this.expandedComment === id ? null : id;
}
}"
x-on:notify.window="
$dispatch('toast', { type: $event.detail.type, message: $event.detail.message })
">
{{-- Notifications --}}
<div x-data="{ toasts: [] }"
x-on:toast.window="
const id = Date.now();
toasts.push({ id, type: $event.detail.type, message: $event.detail.message });
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
"
class="fixed top-4 right-4 z-[100] space-y-2 w-80"
>
<template x-for="toast in toasts" :key="toast.id">
<div x-show="true"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="rounded-lg px-4 py-3 text-white text-sm shadow-lg flex items-center justify-between"
>
<span x-text="toast.message"></span>
<button @click="toasts = toasts.filter(t => t.id !== toast.id)" class="ml-2 text-white/80 hover:text-white">&times;</button>
</div>
</template>
</div>
{{-- Confirmation Modal --}}
<div x-show="showConfirm"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/60"
x-cloak
>
<div x-show="showConfirm"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="scale-95 opacity-0"
x-transition:enter-end="scale-100 opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="scale-100 opacity-100"
x-transition:leave-end="scale-95 opacity-0"
@click.away="showConfirm = false"
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200 dark:border-neutral-700"
>
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="confirmTitle"></h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300" x-text="confirmMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button @click="showConfirm = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
Cancel
</button>
<button @click="executeConfirm()"
:class="confirmButtonClass || 'bg-red-600 hover:bg-red-700'"
class="px-4 py-2 text-sm font-medium text-white rounded-lg transition">
Confirm
</button>
</div>
</div>
</div>
{{-- Main Content --}}
<div class="flex justify-center">
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
{{-- Filter Bar --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Comment Text</label>
<input wire:model.live.debounce.400ms="search" type="search"
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 dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
placeholder="Search comment body...">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Author Username</label>
<input wire:model.live.debounce.400ms="userSearch" type="search"
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 dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
placeholder="Search by username...">
</div>
<div class="flex items-end gap-2">
<button wire:click="clearFilters"
class="h-9 px-3 text-xs font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
Clear Filters
</button>
</div>
</div>
{{-- Per-page and bulk actions bar --}}
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400">Show</label>
<select wire:model.live="perPage"
class="h-8 text-xs 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:text-white px-2">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="text-xs text-gray-500 dark:text-gray-400">per page</span>
</div>
{{-- Bulk Actions --}}
@if(count($selected) > 0)
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ count($selected) }} selected</span>
<button @click="openConfirm('Delete Selected Comments', 'Are you sure you want to delete ' + {{ count($selected) }} + ' comment(s)? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.bulkDelete())"
class="h-8 px-3 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
Delete Selected
</button>
<button @click="openConfirm('Ban Comment Authors', 'Are you sure you want to ban the authors of ' + {{ count($selected) }} + ' comment(s)?', 'bg-rose-600 hover:bg-rose-700', () => $wire.bulkBanAuthors())"
class="h-8 px-3 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
Ban Authors
</button>
</div>
@endif
</div>
</div>
{{-- Table --}}
<div class="relative overflow-x-auto rounded-lg border border-gray-200 dark:border-neutral-700">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white"> <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200 "> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
<tr> <tr>
<th scope="col" class="px-6 py-3 text-center"> <th scope="col" class="px-4 py-3 w-10">
User <input type="checkbox" wire:model.live="selectPage"
<input 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">
wire:model.live.debounce.600ms="userSearch"
type="search"
id="live-search"
class="w-32 h-7 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..."
>
</th> </th>
<th scope="col" class="px-6 py-3"> <th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
Comment wire:click="sortBy('user_id')">
<input <div class="flex items-center gap-1">
wire:model.live.debounce.600ms="search" Author
type="search" @if($sortField === 'user_id')
id="live-search" <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="ml-2 w-32 h-7 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" @if($sortDirection === 'asc')
placeholder="Search..." <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
> @else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@endif
</svg>
@endif
</div>
</th> </th>
<th scope="col" class="px-6 py-3"> <th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
wire:click="sortBy('body')">
<div class="flex items-center gap-1">
Comment
@if($sortField === 'body')
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@if($sortDirection === 'asc')
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
@else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@endif
</svg>
@endif
</div>
</th> </th>
<th scope="col" class="px-6 py-3"> <th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
Actions wire:click="sortBy('created_at')">
<div class="flex items-center gap-1">
Date
@if($sortField === 'created_at')
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@if($sortDirection === 'asc')
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
@else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@endif
</svg>
@endif
</div>
</th> </th>
<th scope="col" class="px-4 py-3">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($comments as $comment) @forelse($comments as $comment)
<tr wire:key="comment-{{ $comment->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700"> <tr wire:key="comment-{{ $comment->id }}"
<td class="px-6 py-4"> class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
{{ $comment->user->name }} <td class="px-4 py-3">
<input type="checkbox" wire:model.live="selected" value="{{ $comment->id }}"
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">
</td> </td>
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg"> <td class="px-4 py-3">
{{ $comment->body }} <div class="flex items-center gap-2">
</th> @if($comment->user)
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg"> <img src="{{ $comment->user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
{{ $comment->created_at }} <div>
</th> <span class="font-medium text-gray-900 dark:text-white">{{ $comment->user->name }}</span>
<td class="px-6 py-4"> @if($comment->user->hasRole(\App\Enums\UserRole::BANNED))
<button wire:click="deleteComment({{$comment->id}})" type="button" class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150 mt-2"> <span class="inline-flex items-center px-1.5 py-0.5 ml-1 rounded text-[10px] font-medium bg-red-600 text-white">Banned</span>
Delete @endif
</button> </div>
@else
<span class="text-gray-400 dark:text-gray-500">Unknown</span>
@endif
</div>
</td>
<td class="px-4 py-3 max-w-lg">
<div x-data="{ expanded: false }" class="relative">
<p x-show="!expanded" class="text-gray-900 dark:text-white line-clamp-2 whitespace-pre-wrap break-words">
{{ $comment->body }}
</p>
<p x-show="expanded" class="text-gray-900 dark:text-white whitespace-pre-wrap break-words">
{{ $comment->body }}
</p>
@if(strlen($comment->body) > 150)
<button @click="expanded = !expanded"
class="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1">
<span x-text="expanded ? 'Show less' : 'Read more'"></span>
</button>
@endif
</div>
</td>
<td class="px-4 py-3 text-xs whitespace-nowrap">
<span title="{{ $comment->created_at->format('Y-m-d H:i:s') }}" class="cursor-help">
{{ $comment->created_at->diffForHumans() }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<button @click="openConfirm('Delete Comment', 'Are you sure you want to delete this comment? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.deleteComment({{ $comment->id }}))"
class="inline-block rounded bg-red-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-red-700 transition">
Delete
</button>
@if($comment->user && !$comment->user->hasRole(\App\Enums\UserRole::BANNED))
<button @click="openConfirm('Ban Author', 'Are you sure you want to ban {{ addslashes($comment->user->name) }}?', 'bg-rose-600 hover:bg-rose-700', () => $wire.banCommentAuthor({{ $comment->id }}))"
class="inline-block rounded bg-rose-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-rose-700 transition">
Ban Author
</button>
@endif
</div>
</td> </td>
</tr> </tr>
@endforeach @empty
<tr class="bg-white dark:bg-neutral-800">
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<p class="text-sm">No comments found matching your filters.</p>
</td>
</tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
{{-- Pagination --}}
<div class="mt-4 flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">
@if($comments->total() > 0)
Showing {{ $comments->firstItem() }} to {{ $comments->lastItem() }} of {{ $comments->total() }} comments
@endif
</div>
<div class="flex items-center gap-1">
{{ $comments->links('pagination::tailwind') }}
</div>
</div>
</div> </div>
{{ $comments->links('pagination::tailwind') }}
</div> </div>
</div> </div>
@@ -1,105 +1,435 @@
<div> <div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage"> x-data="{
<div class="flex items-center justify-center"> showConfirm: false,
<div class="relative overflow-x-auto rounded-lg w-3/6"> confirmAction: null,
confirmTitle: '',
confirmMessage: '',
confirmButtonClass: '',
confirmCallback: null,
openConfirm(title, message, buttonClass, callback) {
this.confirmTitle = title;
this.confirmMessage = message;
this.confirmButtonClass = buttonClass;
this.confirmCallback = callback;
this.showConfirm = true;
},
executeConfirm() {
if (this.confirmCallback) this.confirmCallback();
this.showConfirm = false;
}
}"
x-on:notify.window="
$dispatch('toast', { type: $event.detail.type, message: $event.detail.message })
">
{{-- Notifications --}}
<div x-data="{ toasts: [] }"
x-on:toast.window="
const id = Date.now();
toasts.push({ id, type: $event.detail.type, message: $event.detail.message });
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
"
class="fixed top-4 right-4 z-[100] space-y-2 w-80"
>
<template x-for="toast in toasts" :key="toast.id">
<div x-show="true"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="rounded-lg px-4 py-3 text-white text-sm shadow-lg flex items-center justify-between"
>
<span x-text="toast.message"></span>
<button @click="toasts = toasts.filter(t => t.id !== toast.id)" class="ml-2 text-white/80 hover:text-white">&times;</button>
</div>
</template>
</div>
{{-- Confirmation Modal --}}
<div x-show="showConfirm"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/60"
x-cloak
>
<div x-show="showConfirm"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="scale-95 opacity-0"
x-transition:enter-end="scale-100 opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="scale-100 opacity-100"
x-transition:leave-end="scale-95 opacity-0"
@click.away="showConfirm = false"
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200 dark:border-neutral-700"
>
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="confirmTitle"></h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300" x-text="confirmMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button @click="showConfirm = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
Cancel
</button>
<button @click="executeConfirm()"
:class="confirmButtonClass || 'bg-red-600 hover:bg-red-700'"
class="px-4 py-2 text-sm font-medium text-white rounded-lg transition">
Confirm
</button>
</div>
</div>
</div>
{{-- User Detail Modal --}}
@if($showUserModal && $modalUser)
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-black/60"
x-data="{}"
wire:key="user-modal-{{ $modalUser->id }}"
>
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto border border-gray-200 dark:border-neutral-700"
@click.away="$wire.closeModal()"
>
{{-- Modal Header --}}
<div class="sticky top-0 bg-white dark:bg-neutral-800 border-b border-gray-200 dark:border-neutral-700 px-6 py-4 flex items-center justify-between z-10 rounded-t-xl">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<img src="{{ $modalUser->getAvatar() }}" alt="{{ $modalUser->name }}" class="w-10 h-10 rounded-full object-cover">
{{ $modalUser->name }}
</h2>
<button wire:click="closeModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-white transition">
<svg class="w-6 h-6" 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>
{{-- Modal Body --}}
<div class="px-6 py-4 space-y-6">
{{-- User Info Grid --}}
<div class="grid grid-cols-2 gap-4">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User ID</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">#{{ $modalUser->id }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Email</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->email ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord ID</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">{{ $modalUser->discord_id ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Registered</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->created_at->format('Y-m-d H:i') }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->updated_at->format('Y-m-d H:i') }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Comments</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->commentCount() }}</dd>
</div>
</div>
{{-- Roles --}}
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Roles</dt>
<dd class="flex flex-wrap gap-2">
@php $roleLabels = [
\App\Enums\UserRole::ADMINISTRATOR->value => ['bg-purple-600 text-white', 'Admin'],
\App\Enums\UserRole::MODERATOR->value => ['bg-blue-600 text-white', 'Moderator'],
\App\Enums\UserRole::SUPPORTER->value => ['bg-pink-600 text-white', 'Patreon'],
\App\Enums\UserRole::BANNED->value => ['bg-red-600 text-white', 'Banned'],
]; @endphp
@foreach($roleLabels as $role => $classes)
@if($modalUser->hasRole(\App\Enums\UserRole::from($role)))
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $classes[0] }}">{{ $classes[1] }}</span>
@endif
@endforeach
@if(empty($modalUser->roles))
<span class="text-sm text-gray-400 dark:text-gray-500">No special roles</span>
@endif
</dd>
</div>
{{-- Modal Actions --}}
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-200 dark:border-neutral-700">
@if($modalUser->hasRole(\App\Enums\UserRole::BANNED))
<button wire:click="unbanUser({{ $modalUser->id }})"
class="px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
Unban User
</button>
@else
<button wire:click="banUser({{ $modalUser->id }})"
class="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
Ban User
</button>
@endif
@if($modalUser->hasRole(\App\Enums\UserRole::MODERATOR))
<button wire:click="revokeModerator({{ $modalUser->id }})"
class="px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition">
Revoke Moderator
</button>
@else
<button wire:click="grantModerator({{ $modalUser->id }})"
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
Grant Moderator
</button>
@endif
<button wire:click="deleteUserComments({{ $modalUser->id }})"
class="px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
Delete All Comments
</button>
</div>
{{-- Recent Comments --}}
@if($modalUserComments->isNotEmpty())
<div class="pt-2 border-t border-gray-200 dark:border-neutral-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">Recent Comments (last {{ $modalUserComments->count() }})</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
@foreach($modalUserComments as $comment)
<div class="bg-gray-50 dark:bg-neutral-900 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-300">
<p class="line-clamp-2">{{ $comment->body }}</p>
<span class="text-xs text-gray-400 dark:text-gray-500 mt-1 block">{{ $comment->created_at->diffForHumans() }}</span>
</div>
@endforeach
</div>
</div>
@endif
</div>
{{-- Modal Footer --}}
<div class="sticky bottom-0 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-700 px-6 py-3 rounded-b-xl flex justify-end">
<button wire:click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
Close
</button>
</div>
</div>
</div>
@endif
{{-- Main Table Area --}}
<div class="flex justify-center">
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
{{-- Filter Bar --}}
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Username</label>
<input wire:model.live.debounce.400ms="search" type="search"
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 dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
placeholder="Search username...">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Discord ID</label>
<input wire:model.live.debounce.400ms="discordId" type="search"
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 dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
placeholder="Discord ID...">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Email</label>
<input wire:model.live.debounce.400ms="email" type="search"
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 dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
placeholder="Search email...">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Role Filter</label>
<div class="flex flex-wrap gap-1.5">
@foreach([
'admin' => 'Admin',
'moderator' => 'Moderator',
'supporter' => 'Supporter',
'banned' => 'Banned',
] as $val => $label)
<label class="inline-flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" wire:model.live="roleFilter" value="{{ $val }}"
class="w-3.5 h-3.5 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
<span class="text-gray-700 dark:text-gray-300">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
<div class="flex items-end gap-2">
<button wire:click="clearFilters"
class="h-9 px-3 text-xs font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
Clear Filters
</button>
</div>
</div>
{{-- Per-page and bulk actions bar --}}
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400">Show</label>
<select wire:model.live="perPage"
class="h-8 text-xs 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:text-white px-2">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="text-xs text-gray-500 dark:text-gray-400">per page</span>
</div>
{{-- Bulk Actions --}}
@if(count($selected) > 0)
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ count($selected) }} selected</span>
<button @click="openConfirm('Ban Selected Users', 'Are you sure you want to ban ' + {{ count($selected) }} + ' user(s)?', 'bg-rose-600 hover:bg-rose-700', () => $wire.bulkBan())"
class="h-8 px-3 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
Ban Selected
</button>
<button @click="openConfirm('Unban Selected Users', 'Are you sure you want to unban ' + {{ count($selected) }} + ' user(s)?', 'bg-green-600 hover:bg-green-700', () => $wire.bulkUnban())"
class="h-8 px-3 text-xs font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
Unban Selected
</button>
<button @click="openConfirm('Delete Comments', 'Are you sure you want to delete ALL comments from ' + {{ count($selected) }} + ' user(s)? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.bulkDeleteComments())"
class="h-8 px-3 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
Delete Selected Comments
</button>
</div>
@endif
</div>
</div>
{{-- Table --}}
<div class="relative overflow-x-auto rounded-lg border border-gray-200 dark:border-neutral-700">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white"> <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200 "> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
<tr> <tr>
<th scope="col" class="px-6 py-3"> <th scope="col" class="px-4 py-3 w-10">
ID <input type="checkbox" wire:model.live="selectPage"
</th> 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">
<th scope="col" class="px-6 py-3">
Discord ID
<input
wire:model.live.debounce.600ms="discordId"
type="search"
id="discord-search"
class="ml-2 w-32 h-7 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..."
>
</th>
<th scope="col" class="px-6 py-3">
Username
<input
wire:model.live.debounce.600ms="search"
type="search"
id="live-search"
class="ml-2 w-32 h-7 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..."
>
</th>
<th scope="col" class="px-6 py-3">
Patreon
<input
class="w-4 h-4 ml-2 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"
wire:model.live="patreon"
value="true"
>
</th>
<th scope="col" class="px-6 py-3">
Banned
<input
class="w-4 h-4 ml-2 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"
wire:model.live="banned"
value="true"
>
</th>
<th scope="col" class="px-6 py-3">
Created at
</th>
<th scope="col" class="px-6 py-3">
Updated at
</th>
<th scope="col" class="px-6 py-3">
Actions
</th> </th>
@foreach([
'id' => 'ID',
'discord_id' => 'Discord ID',
'name' => 'Username',
'email' => 'Email',
'created_at' => 'Registered',
'updated_at' => 'Updated',
] as $field => $label)
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
wire:click="sortBy('{{ $field }}')">
<div class="flex items-center gap-1 whitespace-nowrap">
{{ $label }}
@if($sortField === $field)
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@if($sortDirection === 'asc')
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
@else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@endif
</svg>
@endif
</div>
</th>
@endforeach
<th scope="col" class="px-4 py-3">Roles</th>
<th scope="col" class="px-4 py-3">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($users as $user) @forelse($users as $user)
<tr wire:key="user-{{ $user->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700"> <tr wire:key="user-{{ $user->id }}"
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
{{ $user->id }} <td class="px-4 py-3">
</th> <input type="checkbox" wire:model.live="selected" value="{{ $user->id }}"
<td class="px-6 py-4"> 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">
{{ $user->discord_id ?? 'n/a' }}
</td> </td>
<td class="px-6 py-4"> <td class="px-4 py-3 font-mono text-xs">{{ $user->id }}</td>
{{ $user->name }} <td class="px-4 py-3 font-mono text-xs">{{ $user->discord_id ?? 'n/a' }}</td>
</td> <td class="px-4 py-3">
<td class="px-6 py-4"> <button wire:click="viewUser({{ $user->id }})"
{{ $user->hasRole(\App\Enums\UserRole::SUPPORTER) ? 'Yes' : 'No' }} class="font-medium text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-2">
</td> <img src="{{ $user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover">
<td class="px-6 py-4"> {{ $user->name }}
{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'Yes' : 'No' }}
</td>
<td class="px-6 py-4">
{{ $user->created_at->format('Y-m-d') }}
</td>
<td class="px-6 py-4">
{{ $user->updated_at->format('Y-m-d') }}
</td>
<td class="px-6 py-4 flex flex-col gap-1">
<form method="POST" action="{{ route('admin.user.update') }}">
@csrf
<input type="hidden" value="{{ $user->id }}" name="id">
<input type="hidden" value="{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'unban' : 'ban' }}" name="action">
<button type="submit" class="inline-block w-full rounded bg-rose-600 pl-[4px] pr-[4px] p-[1px] text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'Unban' : 'Ban' }}
</button>
</form>
<button wire:click="deleteUserComments('{{ $user->id }}')" class="inline-block w-full rounded bg-red-600 pl-[4px] pr-[4px] p-[1px] text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
Delete comments
</button> </button>
</td> </td>
<td class="px-4 py-3 text-xs">{{ $user->email ?? 'n/a' }}</td>
<td class="px-4 py-3 text-xs whitespace-nowrap">{{ $user->created_at->format('Y-m-d') }}</td>
<td class="px-4 py-3 text-xs whitespace-nowrap">{{ $user->updated_at->format('Y-m-d') }}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
@if($user->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-600 text-white">Admin</span>
@endif
@if($user->hasRole(\App\Enums\UserRole::MODERATOR))
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-600 text-white">Mod</span>
@endif
@if($user->hasRole(\App\Enums\UserRole::SUPPORTER))
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-pink-600 text-white">Supp.</span>
@endif
@if($user->hasRole(\App\Enums\UserRole::BANNED))
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white">Banned</span>
@endif
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
@if($user->hasRole(\App\Enums\UserRole::BANNED))
<button wire:click="unbanUser({{ $user->id }})"
class="inline-block rounded bg-green-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-green-700 transition">
Unban
</button>
@else
<button wire:click="banUser({{ $user->id }})"
class="inline-block rounded bg-rose-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-rose-700 transition">
Ban
</button>
@endif
@if($user->hasRole(\App\Enums\UserRole::MODERATOR))
<button @click="openConfirm('Revoke Moderator', 'Are you sure you want to revoke moderator role from {{ addslashes($user->name) }}?', 'bg-amber-600 hover:bg-amber-700', () => $wire.revokeModerator({{ $user->id }}))"
class="inline-block rounded bg-amber-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-amber-700 transition">
Revoke Mod
</button>
@else
<button wire:click="grantModerator({{ $user->id }})"
class="inline-block rounded bg-blue-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-blue-700 transition">
Grant Mod
</button>
@endif
<button @click="openConfirm('Delete Comments', 'Are you sure you want to delete ALL comments from {{ addslashes($user->name) }}? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.deleteUserComments({{ $user->id }}))"
class="inline-block rounded bg-red-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-red-700 transition">
Del Comments
</button>
</div>
</td>
</tr> </tr>
@endforeach @empty
<tr class="bg-white dark:bg-neutral-800">
<td colspan="9" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<p class="text-sm">No users found matching your filters.</p>
</td>
</tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
{{-- Pagination --}}
<div class="mt-4 flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">
@if($users->total() > 0)
Showing {{ $users->firstItem() }} to {{ $users->lastItem() }} of {{ $users->total() }} users
@endif
</div>
<div class="flex items-center gap-1">
{{ $users->links('pagination::tailwind') }}
</div>
</div>
</div> </div>
{{ $users->links('pagination::tailwind') }}
</div> </div>
</div> </div>
+234 -134
View File
@@ -1,153 +1,253 @@
<div> <div id="comment-{{ $comment->id }}">
<div class="flex" id="comment-{{ $comment->id }}">
<div class="flex-shrink-0 mr-4"> <div class="group flex gap-4">
{{-- Avatar --}}
<div class="shrink-0">
@if($comment->isDeletedByModerator()) @if($comment->isDeletedByModerator())
<img class="h-10 w-10 rounded-full" src="{{ asset('images/default-avatar.webp') }}" alt="Deleted comment"> <img
class="h-10 w-10 rounded-full object-cover opacity-60"
src="{{ asset('images/default-avatar.webp') }}"
alt="Deleted comment"
>
@else @else
<img class="h-10 w-10 rounded-full" src="{{ $comment->user->getAvatar() }}" alt="{{ $comment->user->name }}"> <img
class="h-10 w-10 rounded-full object-cover ring-2 ring-white dark:ring-neutral-800"
src="{{ $comment->user->getAvatar() }}"
alt="{{ $comment->user->name }}"
>
@endif @endif
</div> </div>
<div class="flex-grow">
<div class="flex gap-2">
@if($comment->isDeletedByModerator())
@if (Auth::check() && (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR)))
<p class="font-medium text-gray-900 dark:text-gray-100">Deleted ({{ $comment->user->name }})</p>
@else
<p class="font-medium text-gray-900 dark:text-gray-100">Deleted</p>
@endif
@else
<p class="font-medium text-gray-900 dark:text-gray-100">{{ $comment->user->name }}</p>
@endif
@if($comment->user->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<a data-te-toggle="tooltip" title="Admin"><i class="fa-solid fa-crown text-yellow-600"></i></a>
@endif
@if($comment->user->hasRole(\App\Enums\UserRole::MODERATOR))
<a data-te-toggle="tooltip" title="Admin" class="text-rose-600">Moderator</a>
@endif
@if($comment->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"></i></a>
@endif
</div>
<div class="mt-1 flex-grow w-full">
@if($comment->isDeletedByModerator())
<div class="text-gray-700 dark:text-gray-200">Deleted by moderation.</div>
@if (Auth::check() && (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR)))
<div class="text-gray-700 dark:text-gray-300 pt-1">Original comment: {!! $comment->presenter()->markdownBody() !!}</div>
@endif
@else
@if ($isEditing)
<form wire:submit.prevent="editComment">
<div>
<label for="comment" class="sr-only">Comment body</label>
<textarea id="comment" name="comment" rows="3"
class="bg-white dark:bg-neutral-700 shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 dark:border-gray-400/40 text-gray-900 dark:text-gray-200 placeholder:text-gray-400 rounded-md
@error('editState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="editState.body"></textarea>
@error('editState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mt-3 flex items-center justify-between">
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500">
Edit
</button>
</div>
</form>
@else
<div class="text-gray-700 dark:text-gray-200">{!! $comment->presenter()->markdownBody() !!}</div>
@endif
@endif
</div>
<div class="mt-2 space-x-2 flex flex-row">
<span class="text-gray-500 dark:text-gray-300">
{{ $comment->presenter()->relativeCreatedAt() }}
</span>
@guest {{-- Content --}}
<span data-te-toggle="tooltip" title="Please login to like the episode" class="text-gray-800 cursor-pointer dark:text-gray-200"> <div class="min-w-0 flex-1">
<i class="fa-regular fa-heart"></i> {{ $comment->likeCount() }}
</span>
@endguest
@auth <div class="rounded-2xl border border-neutral-200 bg-white px-5 py-4 shadow-sm transition group-hover:border-neutral-300 dark:border-neutral-800 dark:bg-neutral-800 dark:group-hover:border-neutral-700">
<!-- Like Button -->
<button class="text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap" wire:click="like"> {{-- Header --}}
@if ($liked) <div class="mb-3 flex flex-wrap items-center gap-2">
<i class="fa-solid fa-heart text-rose-600"></i> {{ $likeCount }}
@else @if($comment->isDeletedByModerator())
<i class="fa-solid fa-heart"></i> {{ $likeCount }}
<span class="font-semibold text-neutral-900 dark:text-neutral-100">
@if (Auth::check() && (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR)))
Deleted ({{ $comment->user->name }})
@else
Deleted
@endif
</span>
@else
<span class="font-semibold text-neutral-900 dark:text-neutral-100">
{{ $comment->user->name }}
</span>
@endif
{{-- Badges --}}
@if($comment->user->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400">
<i class="fa-solid fa-crown mr-1"></i>
Admin
</span>
@endif
@if($comment->user->hasRole(\App\Enums\UserRole::MODERATOR))
<span class="inline-flex items-center rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-500/10 dark:text-rose-400">
Moderator
</span>
@endif
@if($comment->user->hasRole(\App\Enums\UserRole::SUPPORTER))
<span class="inline-flex items-center rounded-full bg-pink-100 px-2 py-0.5 text-xs font-medium text-pink-700 dark:bg-pink-500/10 dark:text-pink-400">
<i class="fa-solid fa-heart mr-1"></i>
Supporter
</span>
@endif
</div>
{{-- Body --}}
<div class="prose prose-sm max-w-none dark:prose-invert">
@if($comment->isDeletedByModerator())
<p class="italic text-neutral-500 dark:text-neutral-400">
Deleted by moderation.
</p>
@if (Auth::check() && (Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR)))
<div class="mt-3 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-800">
{!! $comment->presenter()->markdownBody() !!}
</div>
@endif
@else
@if ($isEditing)
<form wire:submit.prevent="editComment" class="space-y-4">
<textarea
rows="4"
wire:model.defer="editState.body"
class="w-full rounded-2xl border border-neutral-300 bg-white px-4 py-3 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/10 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 @error('editState.body') border-red-500 @enderror"
></textarea>
@error('editState.body')
<p class="text-sm text-red-500">
{{ $message }}
</p>
@enderror
<div class="flex justify-end">
<button
type="submit"
class="rounded-xl bg-rose-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-700"
>
Save Changes
</button>
</div>
</form>
@else
<div class="text-gray-700 dark:text-gray-200">
{!! $comment->presenter()->markdownBody() !!}
</div>
@endif @endif
</button>
@endauth
@auth
@if ($comment->depth() < 2)
<button wire:click="$toggle('isReplying')" type="button" class="text-gray-900 dark:text-gray-100 font-medium">
Reply
</button>
@endif @endif
@can ('update', $comment) </div>
<button wire:click="$toggle('isEditing')" type="button" class="text-gray-900 dark:text-gray-100 font-medium">
Edit
</button>
@endcan
@can ('destroy', $comment) {{-- Footer --}}
<button x-data="{ <div class="mt-4 flex flex-wrap items-center gap-4 text-sm">
confirmCommentDeletion () {
if (window.confirm('Are you sure you want to delete this comment?')) { <span class="text-neutral-500 dark:text-neutral-400">
@this.call('deleteComment'); {{ $comment->presenter()->relativeCreatedAt() }}
} </span>
}
}" {{-- Like --}}
@click="confirmCommentDeletion" @guest
type="button" <span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
class="text-gray-900 dark:text-gray-100 font-medium" <i class="fa-regular fa-heart"></i>
> {{ $comment->likeCount() }}
Delete </span>
</button> @endguest
@endcan
@auth
@can ('restore', $comment)
<button <button
wire:click="restoreComment" wire:click="like"
type="button" class="flex items-center gap-1 text-neutral-500 transition hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
class="text-gray-900 dark:text-gray-100 font-medium"
> >
Restore @if ($liked)
<i class="fa-solid fa-heart text-rose-600"></i>
@else
<i class="fa-regular fa-heart"></i>
@endif
{{ $likeCount }}
</button> </button>
@endcan @endauth
@endauth
{{-- Actions --}}
@auth
@if ($comment->depth() < 2)
<button
wire:click="$toggle('isReplying')"
class="font-medium text-neutral-600 transition hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
>
Reply
</button>
@endif
@can ('update', $comment)
<button
wire:click="$toggle('isEditing')"
class="font-medium text-neutral-600 transition hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
>
Edit
</button>
@endcan
@can ('destroy', $comment)
<button
x-data="{
confirmCommentDeletion () {
if (window.confirm('Delete this comment?')) {
@this.call('deleteComment');
}
}
}"
@click="confirmCommentDeletion"
class="font-medium text-red-500 transition hover:text-red-600"
>
Delete
</button>
@endcan
@can ('restore', $comment)
<button
wire:click="restoreComment"
class="font-medium text-emerald-600 transition hover:text-emerald-700"
>
Restore
</button>
@endcan
@endauth
</div>
</div> </div>
{{-- Reply Form --}}
@if ($isReplying)
<div class="mt-4 ml-2">
<form wire:submit.prevent="postReply" class="space-y-4">
<textarea
rows="3"
wire:model.defer="replyState.body"
placeholder="Write a reply..."
class="w-full rounded-2xl border border-neutral-300 bg-white px-4 py-3 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/10 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 @error('replyState.body') border-red-500 @enderror"
></textarea>
@error('replyState.body')
<p class="text-sm text-red-500">
{{ $message }}
</p>
@enderror
<div class="flex justify-end">
<button
type="submit"
class="rounded-xl bg-rose-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-700"
>
Reply
</button>
</div>
</form>
</div>
@endif
{{-- Replies --}}
@if ($comment->children->count())
<div class="mt-2 space-y-2 border-l-2 border-neutral-200 pl-6 dark:border-neutral-700">
@foreach ($comment->children as $child)
<livewire:comment :comment="$child" :key="$child->id"/>
@endforeach
</div>
@endif
</div> </div>
</div> </div>
<div class="ml-14 mt-6">
@if ($isReplying)
<form wire:submit.prevent="postReply" class="my-4">
<div>
<label for="comment" class="sr-only">Reply body</label>
<textarea id="comment" name="comment" rows="3"
class="bg-white dark:bg-neutral-700 shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 dark:border-gray-400/40 text-gray-900 dark:text-gray-200 placeholder:text-gray-400 rounded-md
@error('replyState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="replyState.body"></textarea>
@error('replyState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mt-3 flex items-center justify-between">
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500">
Comment
</button>
</div>
</form>
@endif
@foreach ($comment->children as $child)
<livewire:comment :comment="$child" :key="$child->id"/>
@endforeach
</div>
</div> </div>
+77 -41
View File
@@ -1,57 +1,93 @@
<section> <section>
<div class="bg-white dark:bg-neutral-800 shadow sm:rounded-xl sm:overflow-hidden"> <div id="comments" class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
<div class="px-4 py-5 sm:px-6">
<h2 class="leading-normal font-bold text-lg text-gray-900 dark:text-gray-200">Comments</h2> {{-- Header --}}
<div class="border-b border-neutral-200 px-6 py-5 dark:border-neutral-800">
<h2 class="text-xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">
Comments
</h2>
</div> </div>
<div>
<!-- Comment Input --> {{-- Comment Form --}}
<div class="bg-gray-50 dark:bg-neutral-800 px-4 py-6 sm:px-6 border-t border-b dark:border-neutral-950 border-neutral-200"> <div class="border-b border-neutral-200 bg-neutral-50/80 px-6 py-6 dark:border-neutral-800 dark:bg-neutral-950/40">
@auth @auth
<div class="flex"> <div class="flex gap-4">
<div class="flex-shrink-0 mr-4"> <img
<img class="h-10 w-10 rounded-full" src="{{ auth()->user()->getAvatar() }}" alt="{{ auth()->user()->name }}"> class="h-11 w-11 rounded-full object-cover ring-2 ring-white dark:ring-neutral-800"
</div> src="{{ auth()->user()->getAvatar() }}"
<div class="min-w-0 flex-1"> alt="{{ auth()->user()->name }}"
<form wire:submit.prevent="postComment"> >
<div class="flex-1">
<form wire:submit.prevent="postComment" class="space-y-4">
<div> <div>
<label for="comment" class="sr-only">Comment body</label> <label for="comment" class="sr-only">
<textarea id="comment" name="comment" rows="3" Comment body
class="peer block min-h-[auto] w-full border-1 bg-transparent px-3 py-[0.32rem] leading-[1.6] outline-none transition-all duration-200 ease-linear dark:placeholder:text-neutral-200 border-gray-300 dark:border-neutral-950 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 </label>
@error('newCommentState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="newCommentState.body"></textarea> <textarea
id="comment"
rows="4"
wire:model.defer="newCommentState.body"
placeholder="Write a comment..."
class="w-full rounded-2xl border border-neutral-300 bg-white px-4 py-3 text-sm text-neutral-900 placeholder:text-neutral-400 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/10 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:border-rose-500 @error('newCommentState.body') border-red-500 @enderror"
></textarea>
@error('newCommentState.body') @error('newCommentState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p> <p class="mt-2 text-sm text-red-500">
{{ $message }}
</p>
@enderror @enderror
</div> </div>
<div class="mt-3 flex items-center justify-between">
<button type="submit" <div class="flex justify-end">
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"> <button
Comment type="submit"
class="inline-flex items-center rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-rose-700 focus:outline-none focus:ring-4 focus:ring-rose-500/30"
>
Post Comment
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
@endauth @endauth
@guest @guest
<p class="text-gray-900 dark:text-gray-200">Log in to comment.</p> <div class="rounded-xl border border-dashed border-neutral-300 p-6 text-center dark:border-neutral-700">
@endguest <p class="text-sm text-neutral-600 dark:text-neutral-400">
</div> Log in to join the discussion.
</p>
<!-- Comments -->
<div class="px-4 py-6 sm:px-6">
<div class="space-y-8">
@if ($comments->isNotEmpty())
@foreach($comments as $comment)
<livewire:comment :comment="$comment" :key="$comment->id"/>
@endforeach
{{ $comments->links('pagination::tailwind') }}
@else
<p class="text-gray-900 dark:text-gray-200">No comments yet.</p>
@endif
</div> </div>
</div> @endguest
</div>
{{-- Comments --}}
<div class="px-6 py-6">
@if ($comments->isNotEmpty())
<div class="space-y-6">
@foreach($comments as $comment)
<livewire:comment :comment="$comment" :key="$comment->id"/>
@endforeach
</div>
<div class="mt-8">
{{ $comments->links('pagination::tailwind') }}
</div>
@else
<div class="rounded-2xl border border-dashed border-neutral-300 py-12 text-center dark:border-neutral-700">
<p class="text-neutral-500 dark:text-neutral-400">
No comments yet.
</p>
</div>
@endif
</div> </div>
</div> </div>
</section> </section>
@@ -8,9 +8,17 @@
</div> </div>
<div class="flex flex-col text-center w-full"> <div class="flex flex-col text-center w-full">
@if($fillNumbers) @if($fillNumbers)
<p class="text-lg">Episode {{ str_pad($episodeNumber, 2, '0', STR_PAD_LEFT) }}</p> @if($version)
<p class="text-lg">Episode {{ str_pad($episodeNumber, 2, '0', STR_PAD_LEFT) }} ({{ $version }})</p>
@else
<p class="text-lg">Episode {{ str_pad($episodeNumber, 2, '0', STR_PAD_LEFT) }}</p>
@endif
@else @else
<p class="text-lg">Episode {{ $episodeNumber }}</p> @if($version)
<p class="text-lg">Episode {{ $episodeNumber }} ({{ $version }})</p>
@else
<p class="text-lg">Episode {{ $episodeNumber }}</p>
@endif
@endif @endif
<p class="text-xs">{{ $fileExtension }} MKV {{ $fileSize ?? '' }}</p> <p class="text-xs">{{ $fileExtension }} MKV {{ $fileSize ?? '' }}</p>
<p class="text-xs" id="count-{{ $downloadId }}">Downloaded {{ $downloadCount }} times</p> <p class="text-xs" id="count-{{ $downloadId }}">Downloaded {{ $downloadCount }} times</p>
@@ -1,13 +1,9 @@
<div> <div>
@if (Auth::check()) <button class="inline-flex font-bold items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 dark:bg-white/5 dark:text-gray-200" wire:click="like" wire:poll.90000ms="update">
<div class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap" wire:click="like" wire:poll.90000ms="update">
@else
<div data-te-toggle="tooltip" title="Please login to like the episode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap" wire:poll.60000ms="update">
@endif
@if ($liked) @if ($liked)
<i class="fa-solid fa-heart pr-[4px] text-rose-600"></i> {{ $likeCount }} <i class="fa-solid fa-heart pr-[4px] text-rose-600"></i> {{ $likeCount }}
@else @else
<i class="fa-regular fa-heart pr-[4px]"></i> {{ $likeCount }} <i class="fa-regular fa-heart pr-[4px]"></i> {{ $likeCount }}
@endif @endif
</div> </button>
</div> </div>
@@ -39,11 +39,12 @@
aria-live="polite" aria-live="polite"
> >
<div <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"> 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">
<div class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-neutral-800"> {{-- 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"> <div class="text-sm text-gray-700 dark:text-gray-200 font-medium">
@if($episodes->count()) @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 @else
{{ __('No results') }} {{ __('No results') }}
@endif @endif
@@ -60,46 +61,105 @@
</div> </div>
</div> </div>
{{-- content area: responsive grid --}} {{-- Results List --}}
<div class="p-4"> <div>
@if($episodes->count()) @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) @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"> <li role="option" aria-selected="false">
<div class="relative aspect-video"> <a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}"
<img 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"
alt="{{ $episode->title }} - {{ $episode->episode }}" >
loading="lazy" {{-- Left: Cover Image --}}
class="object-cover w-full h-full" <div class="flex-shrink-0 relative">
src="{{ $episode->gallery->first()->thumbnail_url }}" <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
<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> alt="{{ $episode->title }} - {{ $episode->episode }}"
<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"> loading="lazy"
<i class="fa-regular fa-eye mr-1"></i> {{ $episode->viewCountFormatted() }} class="object-cover w-full h-full group-hover/row:scale-105 transition-transform duration-300"
<i class="fa-regular fa-heart ml-2"></i> {{ $episode->likeCount() }} src="{{ $episode->cover_url }}"
>
</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> </div>
</div>
<div class="p-3"> {{-- Center: Title, Publisher, Tags --}}
<h3 class="text-sm font-semibold truncate text-gray-900 dark:text-white">{{ $episode->title }} - {{ $episode->episode }}</h3> <div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> {{ \Illuminate\Support\Str::limit($episode->description ?? '', 80) }}</p> {{-- Row 1: Title --}}
</div> <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">
</a> {{ $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>
{{-- 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 @endforeach
{{-- Advanced Search card --}} {{-- Advanced Search footer --}}
<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"> <li>
<div class="text-center"> <a href="{{ route('hentai.search', ['search' => $query]) }}"
<div class="text-2xl font-bold text-rose-600 mb-1">🔎</div> 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="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> <div class="flex items-center justify-center gap-2">
</div> <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">
</a> {{ __('Advanced Search') }}
</div> </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>
</li>
</ul>
@else @else
{{-- Empty state --}} {{-- Empty state --}}
<div class="py-12 text-center text-sm text-gray-600 dark:text-gray-300"> <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> <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-block px-4 py-2 rounded-lg bg-rose-700 text-white text-sm hover:bg-rose-800">Try advanced search</a> <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> </div>
@endif @endif
</div> </div>
@@ -108,4 +168,4 @@
@endif @endif
</div> </div>
</form> </form>
</div> </div>
@@ -9,14 +9,65 @@
src="{{ $playlist->user->getAvatar() }}"> src="{{ $playlist->user->getAvatar() }}">
</div> </div>
<div class="flex flex-col justify-center flex-1 pl-4"> <div class="flex flex-col justify-center flex-1 pl-4">
<h1 class="font-bold text-3xl">{{ $playlist->name }}</h1> @if ($editingName)
<div class="flex items-center gap-2 mb-1">
<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"
/>
<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">
Cancel
</button>
</div>
@error('editingPlaylistName')
<p class="text-rose-400 text-sm mb-1">{{ $message }}</p>
@enderror
@else
<h1 class="font-bold text-3xl">
{{ $playlist->name }}
@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">
<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">Episodes: {{ count($playlistEpisodes) }}</p>
<p class="font-light text-lg text-neutral-200">Creator: {{ $playlist->user->name }}</p> <p class="font-light text-lg text-neutral-200">
Creator: {{ $playlist->user->name }}
@auth
@if (Auth::id() === $playlist->user->id)
<span class="ml-3">
<button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300 hover:bg-neutral-500' : 'bg-green-600 text-white hover:bg-green-500' }}">
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</button>
</span>
@else
<span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300' : 'bg-green-600 text-white' }}">
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</span>
@endif
@endauth
</p>
</div> </div>
<div class="flex flex-col justify-center pl-4"> <div class="flex flex-col justify-center pl-4">
<div class="flex justify-end"> <div class="flex justify-end">
<a href="{{ route('hentai.index', ['title' => $playlistEpisodes->first()->episode->slug, 'playlist' => $playlist->id]) }}" @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> 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> </div>
</div> </div>
@@ -1,101 +1,108 @@
<div> <div class="space-y-5">
<div class="mx-auto max-w-5xl px-4 space-y-6"> {{-- 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">
<!-- Filters --> <div class="flex flex-col sm:flex-row gap-3">
<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 --}}
<!-- Search -->
<div class="relative flex-1"> <div class="relative flex-1">
<input <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
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">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<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"/> <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> </svg>
</div> </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> </div>
<!-- Order --> {{-- Order --}}
<select <div class="relative">
wire:model.live="order" <i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
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]" <select
> wire:model.live="order"
<option value="created_at_desc">Newest</option> 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_asc">Oldest</option> >
</select> <option value="created_at_desc">Newest</option>
<option value="created_at_asc">Oldest</option>
</select>
</div>
</div> </div>
</div>
<!-- Comments --> {{-- Comments --}}
<div class="space-y-4"> <div class="space-y-3">
@forelse ($comments as $comment) @forelse ($comments as $comment)
@php @php
$model = $comment->commentable; $model = $comment->commentable;
$episode = $model instanceof \App\Models\Episode $episode = $model instanceof \App\Models\Episode
? $model ? $model
: $model->episodes->first(); : $model->episodes->first();
$url = route('hentai.index', ['title' => $model->slug]); $url = route('hentai.index', ['title' => $model->slug]);
@endphp @endphp
<a href="{{ $url }}#comment-{{ $comment->id }}" <a href="{{ $url }}#comment-{{ $comment->id }}"
wire:key="comment-{{ $comment->id }}" wire:key="comment-{{ $comment->id }}"
class="block group"> 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"> <div class="flex flex-col sm:flex-row">
<!-- Thumbnail --> {{-- Thumbnail --}}
<div class="sm:w-48 shrink-0"> <div class="sm:w-44 shrink-0">
<img <img
src="{{ $episode->gallery->first()->thumbnail_url }}" src="{{ $episode->gallery->first()->thumbnail_url }}"
alt="" alt="{{ $episode->title ?? '' }}"
class="w-full h-40 sm:h-full object-cover" 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 min-w-0">
{{-- 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> </div>
<!-- Content --> {{-- Meta --}}
<div class="flex-1 p-4 flex flex-col justify-between"> <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">
<!-- Comment --> <i class="fa-solid fa-clock text-[10px]"></i>
<div class="text-gray-800 dark:text-gray-200 text-sm line-clamp-3"> {{ $comment->presenter()->relativeCreatedAt() }}
{!! $comment->presenter()->markdownBody() !!} </span>
</div> <span class="text-rose-600 dark:text-rose-400 font-medium group-hover:underline">
View comment
<!-- Meta --> </span>
<div class="flex items-center justify-between mt-3 text-xs text-gray-700 dark:text-gray-400">
<span>
{{ $comment->presenter()->relativeCreatedAt() }}
</span>
<span class="text-rose-600 font-medium group-hover:underline">
View comment
</span>
</div>
</div> </div>
</div> </div>
</div> </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>
</div> </div>
@endforelse </a>
</div> @empty
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<!-- Pagination --> <div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<div> <i class="fa-solid fa-comment-slash text-3xl text-gray-400 dark:text-gray-500"></i>
{{ $comments->links('pagination::tailwind') }} </div>
</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> </div>
{{-- Pagination --}}
<div>
{{ $comments->links('pagination::tailwind') }}
</div>
</div> </div>
+127 -15
View File
@@ -1,27 +1,139 @@
<div> <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%]"> <div class="space-y-5">
@include('livewire.partials.search-filter') {{-- Slim Profile Filter --}}
</div> <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">
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" /> <div class="flex flex-col sm:flex-row gap-3">
<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"> {{-- Search --}}
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }} <div class="relative flex-1">
<div class="flex items-center justify-center"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<div class="flex justify-center"> <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 }}" />
{{-- Results --}}
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
<div class="mt-4">
@if ($view == 'thumbnail') @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 @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 @endif
@forelse($episodes as $episode) @forelse($episodes as $episode)
@include('livewire.partials.search-result') @include('livewire.partials.search-result')
@empty @empty
<div class="col-span-full"> <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> <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 @endforelse
</div> </div>
</div> </div>
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
</div> </div>
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
</div> </div>
</div </div>
@@ -1,54 +0,0 @@
<div class="grid grid-cols-1 lg:grid-cols-3">
<!-- Subscription Card -->
<section class="lg:col-span-3 rounded-2xl border border-white/10 shadow-black/20 overflow-hidden p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<div class="p-6 border-b border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Subscription Status</h3>
<p class="p-2 text-sm dark:text-gray-200 text-gray-800">
Your current membership status for unlimited 4k Downloads.
</p>
</div>
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium border
{{ $isActive ? 'bg-green-500/10 text-green-300 border-green-500/20' : 'bg-red-500/10 text-red-300 border-red-500/20' }}">
<span class="h-2 w-2 rounded-full {{ $isActive ? 'bg-green-400' : 'bg-red-400' }}"></span>
{{ $isActive ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
<!-- Subscription Access Key -->
<div class="lg:col-span-3 rounded-2xl border border-blue-400/20 bg-blue-500/[0.06] shadow-2xl shadow-blue-950/20 p-6 mt-4">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Subscription Access Key</h3>
<p class="p-2 text-sm dark:text-gray-200 text-gray-800">
Paste your subscription key to apply the membership status.
</p>
</div>
<div class="w-full lg:w-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input
id="subscriptionKey"
type="text"
value="{{ $subscriptionKey }}"
wire:model="subscriptionKey"
class="w-full sm:w-[420px] rounded-xl border border-white/10 dark:bg-gray-950/80 px-4 py-3 font-mono text-sm text-blue-400 dark:text-blue-200 outline-none focus:border-blue-400/50"
>
<button
type="button"
wire:click="applyKey"
class="rounded-xl bg-rose-500 px-5 py-3 text-sm font-semibold text-white hover:bg-rose-400 transition shadow-lg shadow-rose-500/20"
>
Apply
</button>
</div>
@error('subscriptionKey') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
</div>
</div>
</section>
</div>
@@ -1,5 +0,0 @@
<div>
<a class="text-xl text-gray-800 dark:text-gray-200 leading-tight whitespace-nowrap" wire:poll.90000ms="update">
<i class="fa-regular fa-eye pr-0.5"></i> {{ $viewCount }}
</a>
</div>
+43 -20
View File
@@ -1,28 +1,51 @@
<div> <div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage" class="text-gray-900 dark:text-white">
<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%]" <div class="relative">
wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage"> {{-- Timeline --}}
<ol class="border-l border-neutral-300 dark:border-neutral-500"> <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) @foreach ($watchedGrouped as $day => $episodes)
<li> <li class="mb-10 ml-6 sm:ml-8 last:mb-0">
<div class="flex items-center pt-3 flex-start"> {{-- Timeline Dot --}}
<div class="-ml-[5px] mr-3 h-[9px] w-[9px] rounded-full bg-neutral-300 dark:bg-neutral-500"> <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">
</div> <i class="fa-solid fa-circle text-[6px] text-white"></i>
<p class="text-sm text-neutral-500 dark:text-neutral-300"> </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]) }} {{ $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>
<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-5"> {{-- Episodes Grid --}}
@foreach ($episodes as $episode) <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<div class="mt-2 mb-6 ml-4"> @foreach ($episodes as $episode)
<x-episode-cover :episode="$episode->episode" view="thumbnail" /> <x-episode-cover :episode="$episode->episode" view="thumbnail" />
</div> @endforeach
@endforeach
</div>
</div> </div>
</li> </li>
@endforeach @endforeach
</ol> </ol>
{{ $watched->links('pagination::tailwind') }}
{{-- 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>
</div </div>
@@ -13,96 +13,71 @@
<!--Modal body--> <!--Modal body-->
<div class="relative p-4"> <div class="relative p-4">
<!-- Add to existing playlist -->
@php $playlists = Auth::user()->playlists; @endphp @php $playlists = Auth::user()->playlists; @endphp
@if (count($playlists) > 0) @if (count($playlists) > 0)
<!-- Add to existing playlist -->
<div class="p-4"> <div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="playlist">Select Playlist:</label> <label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="playlist">Select Playlist:</label>
<select name="playlist" id="playlist" 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"> <select name="playlist" id="playlist" 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">
@foreach($playlists as $playlist) @foreach($playlists as $playlist)
<option value="{{ $playlist->id }}"> <option value="{{ $playlist->id }}">
{{ $playlist->name }} - {{ $playlist->name }} -
{{ $playlist->is_private == 1 ? 'Private' : 'Public' }} - {{ $playlist->is_private == 1 ? 'Private' : 'Public' }} -
{{ $playlist->episodes->count() }} Episodes {{ $playlist->episodes->count() }} Episodes
{{ $playlist->episodes->contains('episode_id', $episode->id) ? '- Episode Already Added' : '' }} {{ $playlist->episodes->contains('episode_id', $episode->id) ? '- Episode Already Added' : '' }}
</option> </option>
@endforeach @endforeach
</select> </select>
<x-input-error :messages="$errors->get('playlist')" class="mt-2" /> <x-input-error :messages="$errors->get('playlist')" class="mt-2" />
</div> </div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4 gap-3"> <div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4 gap-3">
<a <a
data-te-modal-dismiss data-te-modal-dismiss
id="playlist-cancel" id="playlist-cancel"
class="cursor-pointer rounded-xl border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800"> class="cursor-pointer rounded-xl border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
Cancel Cancel
</a> </a>
<a <a
id="playlist-add" id="playlist-add"
class="cursor-pointer rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700"> class="cursor-pointer rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700">
Add Add
</a> </a>
</div> </div>
<hr class="my-4 border-neutral-200 dark:border-neutral-700">
<p class="px-4 text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">Or Create a New Playlist</p>
@else
<p class="px-4 text-sm text-neutral-600 dark:text-neutral-400">
No Playlists found. Create one below!
</p>
@endif
<!-- Create new playlist -->
<div class="p-4"> <div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Enter Playlist Name Here:</label> <label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="playlist-name">Playlist Name:</label>
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" required autofocus/> <x-text-input id="playlist-name" class="block mt-1 w-full" type="text" name="name" maxlength="30" required />
<x-input-error :messages="$errors->get('name')" class="mt-2" /> <x-input-error :messages="$errors->get('name')" class="mt-2" />
</div> </div>
<div class="p-4"> <div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="visiblity">Visiblity:</label> <label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="playlist-visibility">Visibility:</label>
<select name="visiblity" id="visiblity" 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"> <select name="visiblity" id="playlist-visibility" 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">
<option value="public">Public</option> <option value="public">Public</option>
<option value="private" selected>Private</option> <option value="private" selected>Private</option>
</select> </select>
<x-input-error :messages="$errors->get('visiblity')" class="mt-2" /> <x-input-error :messages="$errors->get('visiblity')" class="mt-2" />
</div> </div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4"> <div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<a <a
id="playlist-create-and-add" id="playlist-create-and-add"
class="cursor-pointer rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700"> class="cursor-pointer rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700">
Create and Add Episode Create and Add Episode
</a> </a>
</div> </div>
@else
<!-- Create Playlist -->
<a class="font-semibold text-gray-800 dark:text-gray-200 leading-tight">
No Playlists found. Please create a Playlist first!
</a>
<div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Enter Playlist Name Here:</label>
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" required autofocus/>
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<div class="mt-5 p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="visiblity">Visiblity:</label>
<select name="visiblity" id="visiblity" 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">
<option value="public">Public</option>
<option value="private" selected>Private</option>
</select>
<x-input-error :messages="$errors->get('visiblity')" class="mt-2" />
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<a id="playlist-cancel" class="inline-block cursor-pointer 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
</a>
<a id="playlist-create-and-add" class="ml-1 cursor-pointer 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 and Add Episode
</a>
</div>
@endif
</div> </div>
</div> </div>
</div> </div>
@@ -8,7 +8,7 @@
class="fixed inset-0 z-[1055] hidden overflow-y-auto bg-black/60 backdrop-blur-sm" class="fixed inset-0 z-[1055] hidden overflow-y-auto bg-black/60 backdrop-blur-sm"
> >
<div data-te-modal-dialog-ref class="flex min-h-screen items-center justify-center p-4"> <div data-te-modal-dialog-ref class="flex min-h-screen items-center justify-center p-4">
<div class="relative w-full max-w-4xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900"> <div class="relative w-full max-w-2xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
<x-modal-header :title="__('Create Playlist')" /> <x-modal-header :title="__('Create Playlist')" />
<!--Modal body--> <!--Modal body-->
@@ -17,25 +17,30 @@
@csrf @csrf
<div class="p-4"> <div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Enter Playlist Name Here:</label> <label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Playlist Name:</label>
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" required autofocus/> <x-text-input id="name" class="block mt-1 w-full" type="text" name="name" maxlength="30" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" /> <x-input-error :messages="$errors->get('name')" class="mt-2" />
</div> </div>
<div class="mt-5 p-4"> <div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="visiblity">Visiblity:</label> <label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="visiblity">Visibility:</label>
<select name="visiblity" id="visiblity" 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"> <select name="visiblity" id="visiblity" 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">
<option value="public">Public</option> <option value="public">Public</option>
<option value="private" selected>Private</option> <option value="private" selected>Private</option>
</select> </select>
<x-input-error :messages="$errors->get('visiblity')" class="mt-2" /> <x-input-error :messages="$errors->get('visiblity')" class="mt-2" />
</div> </div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4"> <div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4 gap-3">
<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"> <button
type="button"
data-te-modal-dismiss
class="cursor-pointer rounded-xl border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
Cancel Cancel
</button> </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"> <button
type="submit"
class="cursor-pointer rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700">
Create Create
</button> </button>
</div> </div>
@@ -43,4 +48,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@
@php @php
$download = $hdl->getDownloadByType('FHDi'); $download = $hdl->getDownloadByType('FHDi');
$downloadURL = $dldomains[array_rand($dldomains)].'/'.$download->url; $downloadURL = $dldomains[array_rand($dldomains)].'/'.$download->url;
$version = str_contains($download->url, 'v2') ? 'v2' : '';
@endphp @endphp
<livewire:download-button <livewire:download-button
@@ -14,5 +15,6 @@
:download-count="$download->count" :download-count="$download->count"
:episode-number="$hdl->episode" :episode-number="$hdl->episode"
:fill-numbers="$fillNumbers" :fill-numbers="$fillNumbers"
:file-size="$download->getFileSize()"> :file-size="$download->getFileSize()"
:version="$version">
@endif @endif
@@ -10,6 +10,7 @@
$now = \Illuminate\Support\Carbon::now(); $now = \Illuminate\Support\Carbon::now();
$expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6)); $expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6));
$file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url); $file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url);
$version = str_contains($download->url, 'v2') ? 'v2' : '';
$downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire; $downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire;
@endphp @endphp
@@ -20,5 +21,6 @@
:download-count="$download->count" :download-count="$download->count"
:episode-number="$hdl->episode" :episode-number="$hdl->episode"
:fill-numbers="$fillNumbers" :fill-numbers="$fillNumbers"
:file-size="$download->getFileSize()"> :file-size="$download->getFileSize()"
:version="$version">
@endif @endif
@@ -10,6 +10,7 @@
$now = \Illuminate\Support\Carbon::now(); $now = \Illuminate\Support\Carbon::now();
$expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6)); $expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6));
$file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url); $file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url);
$version = str_contains($download->url, 'v2') ? 'v2' : '';
$downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire; $downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire;
@endphp @endphp
@@ -20,5 +21,6 @@
:download-count="$download->count" :download-count="$download->count"
:episode-number="$hdl->episode" :episode-number="$hdl->episode"
:fill-numbers="$fillNumbers" :fill-numbers="$fillNumbers"
:file-size="$download->getFileSize()"> :file-size="$download->getFileSize()"
:version="$version">
@endif @endif
+8 -16
View File
@@ -1,20 +1,17 @@
<head> <head>
<!-- Meta -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@if (Route::currentRouteName() == 'hentai.index') @if (isset($socialSlot))
@if (isset($episode)) {{ $socialSlot }}
<title>Watch {{ $episode->title }} - {{ $episode->episode }} in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
@elseif (isset($hentai))
<title>Watch {{ $hentai->episodes[0]->title }} in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
@else
<title>Watch Highest Quality Hentai in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
@endif
@else @else
<title>Watch Highest Quality Hentai in 4k, 1080p, UHD, HD for Free - hstream.moe</title> @include('partials.social-home-preview')
<meta name="description" content="The best free 4k hentai site you will ever need online! Free 1080p hentai downloads and 4k hentai streams!">
@endif @endif
<!-- Sitemap -->
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link rel="preload" href="https://fonts.bunny.net/figtree/files/figtree-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="https://fonts.bunny.net/figtree/files/figtree-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
@@ -37,11 +34,6 @@
<meta name="msapplication-navbutton-color" content="#be123c"> <meta name="msapplication-navbutton-color" content="#be123c">
<meta name="apple-mobile-web-app-status-bar-style" content="#be123c"> <meta name="apple-mobile-web-app-status-bar-style" content="#be123c">
<!-- OG -->
@if (isset($episode) || isset($hentai))
@include('partials.social-preview')
@endif
<script> <script>
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
</script> </script>
@@ -0,0 +1,84 @@
@php
$title = '';
if (! isset($episode) && isset($hentai)) {
$episode = $hentai->episodes[0];
$gallery = $episode->gallery;
$title = "{$episode->title} - Watch All Episodes English Subbed in 4K | hstream.moe";
$canonical = url('/hentai/'.$hentai->slug);
} else {
$title = "{$episode->title} Episode {$episode->episode} - English Subbed 4K Stream | hstream.moe";
$canonical = url('/hentai/'.$episode->slug);
}
$description = Str::limit(
preg_replace('/\s+/', ' ', strip_tags($episode->description)),
160
);
@endphp
<!-- Site meta -->
<title>{{ $title }}</title>
<link rel="canonical" href="{{ $canonical }}">
<meta name="robots" content="index,follow,max-image-preview:large">
<meta name="description" content="{{ $description }}">
<!-- Open Graph -->
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $description }}">
<meta property="og:type" content="video.other">
<meta property="og:url" content="{{ $canonical }}">
<meta property="og:locale" content="en_US">
<meta property="og:site_name" content="hstream.moe">
@if(isset($gallery) && $episode->gallery->isNotEmpty())
<meta property="og:image" content="{{ url($gallery[0]->image_url) }}">
<meta property="og:image:secure_url" content="{{ url($gallery[0]->image_url) }}">
<meta property="og:image:alt" content="{{ $episode->title }} Episode {{ $episode->episode }}">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="720">
<meta property="og:image:type" content="image/webp">
@endif
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $title }}">
<meta name="twitter:description" content="{{ $description }}">
@if($episode->gallery->isNotEmpty())
<meta name="twitter:image" content="{{ url($episode->gallery[0]->image_url) }}">
<meta name="twitter:image:alt" content="{{ $episode->title }} Episode {{ $episode->episode }}">
@endif
<!-- JSON-LD -->
@if (!isset($hentai))
@php
$data = [
"@context" => "https://schema.org",
"@type" => "VideoObject",
"name" => "$episode->title Episode $episode->episode",
"description" => $description,
"url" => $canonical,
"uploadDate" => $episode->created_at->toIso8601String()
];
if ( $episode->gallery->isNotEmpty() ) {
$data["thumbnailUrl"] = url($episode->gallery[0]->image_url);
}
$tags = [];
foreach($episode->tags as $tag) {
$tags[] = $tag->name;
}
$data["genre"] = $tags;
$data["interactionStatistic"] = [
"@type" => "InteractionCounter",
"interactionType" => [
"@type" => "WatchAction"
],
"userInteractionCount" => $episode->view_count
];
@endphp
<script type="application/ld+json">
{!! json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@@ -0,0 +1,31 @@
<!-- Site meta -->
<title>Watch English Subbed Hentai Online in HD & 4K | hstream.moe</title>
<meta name="description" content="Stream English subbed hentai online in HD, Full HD and 4K quality. Browse series, episodes and playlists with regular updates.">
<link rel="canonical" href="{{ url()->current() }}">
<!-- Indexing -->
@php
$noIndex = [
'hentai.search',
'playlist.index'
]
@endphp
@if (in_array(Route::currentRouteName(), $noIndex))
<meta name="robots" content="noindex,follow">
@else
<meta name="robots" content="index,follow">
@endif
<!-- Open Graph -->
<meta property="og:title" content="Watch English Subbed Hentai Online in HD & 4K | hstream.moe">
<meta property="og:description" content="Stream English subbed hentai online in HD, Full HD and 4K quality. Browse series, episodes and playlists with regular updates.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://hstream.moe/">
<meta property="og:image" content="https://hstream.moe/images/cropped-HS-1-192x192.webp">
<meta property="og:image:alt" content="hstream.moe">
<meta property="og:image:width" content="192">
<meta property="og:image:height" content="192">
<meta property="og:locale" content="en_US">
<meta property="og:site_name" content="hstream.moe">
<meta name="twitter:card" content="summary_large_image">
@@ -1,52 +0,0 @@
@if (Route::currentRouteName() == 'hentai.index')
@php
$title = '';
if (! isset($episode) && isset($hentai)) {
$episode = $hentai->episodes[0];
$gallery = $episode->gallery;
$title = 'Watch all episodes from '.$episode->title.' in 4k, 2160p, 1080p, UHD, HD - hstream.moe';
} else {
$title = 'Watch '.$episode->title.' - '.$episode->episode.' in 4k, 2160p, 1080p, UHD, HD - hstream.moe';
}
@endphp
<meta property="og:title" content="{{ $title }}" />
<meta property="og:description" content="{{ Str::limit($episode->description, 250) }}" />
<meta property="og:type" content="video.episode" />
<meta property="og:url" content="https://hstream.moe/hentai/{{ $episode->slug }}" />
@if(isset($gallery) && $episode->gallery->isNotEmpty())
<meta property="og:image" content="https://hstream.moe{{ $gallery[0]->image_url }}" />
@endif
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="hstream.moe" />
<meta name="twitter:card" content="summary_large_image" />
@php
$tags = '';
foreach ($episode->tags as $tag) {
if (!$tags){
$tags .= $tag->name;
} else {
$tags .= ', '.$tag->name;
}
}
@endphp
<meta name="description" content="{{ $episode->description }} {{ $tags }}">
<meta name="keywords" content="hstream,hentaistream,hentai,stream,4k,hentai 4k,4k hentai,download,free">
@elseif (Route::currentRouteName() == 'home.index')
<meta property="og:title" content="Watch Hentai in 4k, 1080p, 720p, UHD, HD for Free! - hstream.moe" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hstream.moe/" />
<meta property="og:image" content="https://hstream.moe/images/cropped-HS-1-192x192.webp" />
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="100" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="hstream.moe" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="description" content="Watch Hentai free download in 4k, 2160p, 1080p, UHD, HD! Daily upload, no ads, subbed in 2160p!">
@else
<meta name="description" content="Watch Hentai free download in 4k, 2160p, 1080p, UHD, HD! Daily upload, no ads, subbed in 2160p!">
@endif
+11 -11
View File
@@ -1,12 +1,12 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<div {{-- Header --}}
class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-20 mt-[65px] flex flex-row justify-center md:justify-normal"> <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row"> <i class="fa-solid fa-comment text-rose-500"></i>
@include('profile.partials.sidebar') {{ __('nav.comments') }}
<div class="flex flex-col gap-2"> </h2>
<livewire:user-comments :model="$user"/>
</div> {{-- Content from Livewire --}}
</div> <livewire:user-comments :model="$user"/>
</div> </div>
</x-app-layout> </x-profile-layout>
+121 -7
View File
@@ -1,9 +1,123 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-6">
<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"> {{-- Welcome Banner --}}
<div class="grid md:grid-flow-col gap-4 w-5/6 flex-row"> <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">
@include('profile.partials.sidebar') <div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMiIvPjwvZz48L2c+PC9zdmc+')] opacity-50"></div>
@include('profile.partials.info') <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>
@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>
</div> </div>
</x-app-layout> </x-profile-layout>
+12 -7
View File
@@ -1,9 +1,14 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<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"> {{-- Header --}}
<div class="flex flex-col md:flex-row"> <div class="flex items-center justify-between">
@include('profile.partials.sidebar') <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
@livewire('user-likes') <i class="fa-solid fa-heart text-rose-500"></i>
{{ __('nav.likes') }}
</h2>
</div> </div>
{{-- Content from Livewire --}}
@livewire('user-likes')
</div> </div>
</x-app-layout> </x-profile-layout>
+62 -33
View File
@@ -1,55 +1,84 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<div {{-- Header --}}
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 items-center justify-between">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row"> <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
@include('profile.partials.sidebar') <i class="fa-solid fa-bell text-rose-500"></i>
<div class="flex flex-col gap-2"> 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) @forelse($notifications as $notification)
<div <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">
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">
<!-- Content --> {{-- Unread indicator --}}
<div class="flex flex-col gap-2 w-full h-full mt-2"> @if(is_null($notification->read_at))
<div class="flex items-center justify-between flex-none h-2"> <div class="absolute left-0 top-0 bottom-0 w-1 bg-rose-500"></div>
<span class="text-xs font-semibold uppercase tracking-wide text-sky-600 dark:text-rose-500"> @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' }} {{ $notification->data['type'] ?? 'Notification' }}
</span> </span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ $notification->created_at->diffForHumans(['parts' => 1]) }}
</span>
</div> </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'] ?? '' }} {{ $notification->data['message'] ?? '' }}
</p> </p>
</div> </div>
<!-- Actions --> {{-- Actions --}}
<div class="flex gap-2 sm:gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<a href="{{ $notification->data['url'] }}" @if(isset($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"> <a href="{{ $notification->data['url'] }}"
Open 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">
</a> Open
<i class="fa-solid fa-arrow-right text-[10px]"></i>
</a>
@endif
<form method="POST" action="{{ route('profile.notifications.delete') }}"> <form method="POST" action="{{ route('profile.notifications.delete') }}">
@csrf @csrf
@method('delete') @method('delete')
<input type="hidden" value="{{ $notification->id }}" name="id"> <input type="hidden" value="{{ $notification->id }}" name="id">
<button type="submit" <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"> 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"
Delete title="Delete">
<i class="fa-solid fa-xmark text-sm"></i>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
@empty </div>
<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>
@endforelse
</div> </div>
</div> @empty
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-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>
</x-app-layout> </x-profile-layout>
@@ -1,23 +1,27 @@
@auth @auth
<div <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">
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-1.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.subscription')) 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.subscription') }}"><i class="fa-solid fa-hand-holding-dollar pr-4"></i></i>
Subscription</a>
<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" <a href="{{ route('profile.settings') }}"
href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i> class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
Settings</a> @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') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" <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="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">
class="fa-solid fa-right-from-bracket pr-4"></i> <i class="fa-solid fa-right-from-bracket w-5 text-center text-base text-gray-400 dark:text-gray-500"></i>
Logout</button> Logout
</button>
</form> </form>
</div> </div>
</div> </div>
@endauth @endauth
@@ -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 <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">
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"> <div class="relative">
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="{{ $user->getAvatar() }}"> {{-- Profile Banner Gradient --}}
<div class="flex flex-col py-5 pl-24"> <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>
<strong class="text-slate-900 text-xl font-bold dark:text-slate-200">
{{ $user->name }} {{-- Avatar overlapping the banner --}}
@if ($user->hasRole(\App\Enums\UserRole::SUPPORTER)) <div class="flex justify-center -mt-10">
<a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i <div class="relative">
class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <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"
@endif src="{{ auth()->user()->getAvatar() }}"
</strong> alt="{{ auth()->user()->name }}">
<span class="text-slate-500 text-sm font-medium dark:text-slate-400">Joined @if(auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER))
{{ $user->created_at->format('Y-m') }}</span> <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
</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>
</div> </div>
@@ -1,37 +1,96 @@
<div class="flex flex-col"> <div>
{{-- Profile Card --}}
@include('profile.partials.profile') @include('profile.partials.profile')
<div {{-- Desktop Navigation (hidden on mobile) --}}
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="hidden md:block">
<div class="flex flex-col p-2"> <nav
<a href="{{ route('profile.show') }}" 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">
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 <div class="flex flex-col p-1.5">
class="fa-solid fa-user pr-4"></i> {{ __('nav.profile') }}</a> <a href="{{ route('profile.show') }}"
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') }}" <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="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
class="fa-solid fa-bell pr-4"></i> Notifications</a> @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') }}" <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="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}</a> @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') }}" <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="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
class="fa-solid fa-eye pr-4"></i> @if(request()->routeIs('user.watched'))
{{ __('nav.watched') }}</a> 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') }}" <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="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
class="fa-solid fa-comment pr-4"></i> @if(request()->routeIs('profile.comments'))
{{ __('nav.comments') }}</a> 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') }}" <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="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
class="fa-solid fa-rectangle-list pr-4"></i> @if(request()->routeIs('profile.playlists'))
{{ __('nav.playlists') }}</a> 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]
</div> @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> </div>
@include('profile.partials.actions') {{-- Mobile Navigation (horizontal scroll tabs, visible only on mobile) --}}
</div> @include('profile.partials.mobile-nav')
</div>
@@ -1,64 +1,151 @@
<div> <div>
<div class="grid-cols-1 sm:grid md:grid-cols-3 "> @if(count($playlists) > 0)
@if(count($playlists) > 0) <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@foreach($playlists as $playlist) @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
@if($count > 0)
@php @php
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position', 'desc')->first(); $count = $playlist->episodes->count();
@endphp @endphp
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}" alt="Hollywood Sign on The Hill" /> <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">
@else {{-- Thumbnail --}}
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
@endif
</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">
{{ $count }} Episodes - {{ $playlist->is_private == 1 ? 'Private' : 'Public' }}
@if($count > 0) @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> <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="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>
@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 @endif
</p>
<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> {{-- Content --}}
</div> <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 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-500 dark:text-neutral-400">Name</label>
<input
type="text"
name="name"
x-model="name"
maxlength="30"
required
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white focus:border-rose-500 focus:ring-rose-500"
/>
</div>
<div>
<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"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white focus:border-rose-500 focus:ring-rose-500"
>
<option value="0">Public</option>
<option value="1">Private</option>
</select>
</div>
</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-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>
</form>
</div>
</template>
{{-- Display mode --}}
<template x-if="!editing">
<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>
<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 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 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> </div>
@endforeach @else
<!-- Add Another Playlist --> {{-- Empty State --}}
<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"> <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">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-40" alt="..." /> <div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<div class="p-6"> <i class="fa-solid fa-rectangle-list text-3xl text-gray-400 dark:text-gray-500"></i>
<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>
</div> </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> </div>
@else @endif
<!-- 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>
</div>
</div>
@endif
</div>
</div> </div>
+22 -12
View File
@@ -1,14 +1,24 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row"> {{-- Header --}}
<div class="flex flex-col md:flex-row"> <div class="flex items-center justify-between">
@include('profile.partials.sidebar') <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6"> <i class="fa-solid fa-rectangle-list text-rose-500"></i>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg"> {{ __('nav.playlists') }}
@include('profile.partials.user-playlists') </h2>
</div> <button
</div> data-te-toggle="modal"
@include('modals.create-playlist') 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> </div>
{{-- Content --}}
@include('profile.partials.user-playlists')
{{-- Modal --}}
@include('modals.create-playlist')
</div> </div>
</x-app-layout> </x-profile-layout>
+111 -15
View File
@@ -1,30 +1,126 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 mb-14 flex flex-row"> {{-- Header --}}
<div class="flex flex-col md:flex-row"> <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
@include('profile.partials.sidebar') <i class="fa-solid fa-gear text-rose-500"></i>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6"> Settings
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg"> </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') @include('profile.partials.update-profile-information-form')
</div> </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 /> <livewire:passkeys />
</div> </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') @include('profile.partials.update-password-form')
</div> </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') @include('profile.partials.update-blacklist-form')
</div> </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') @include('profile.partials.update-design-form')
</div> </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') @include('profile.partials.delete-user-form')
</div> </div>
@include('profile.partials.delete-user-modal')
</div> </div>
@vite(['resources/js/user-blacklist.js'])
</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>
@@ -1,11 +0,0 @@
<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 mt-8 md:mt-0 space-y-6">
@livewire('user-subscription', ['user' => $user])
</div>
</div>
</div>
</x-app-layout>
+11 -8
View File
@@ -1,9 +1,12 @@
<x-app-layout> <x-profile-layout>
@include('partials.background') <div class="space-y-5">
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row"> {{-- Header --}}
<div class="flex flex-col md:flex-row"> <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
@include('profile.partials.sidebar') <i class="fa-solid fa-eye text-rose-500"></i>
@livewire('watched', ['user' => $user]) {{ __('nav.watched') }}
</div> </h2>
{{-- Content from Livewire --}}
@livewire('watched', ['user' => $user])
</div> </div>
</x-app-layout> </x-profile-layout>
+6 -1
View File
@@ -1,9 +1,14 @@
<x-app-layout> <x-app-layout>
<x-slot name="socialSlot">
@include('partials.social-hentai-preview', ['hentai' => $hentai])
</x-slot>
@php $episode = $hentai->episodes[0]; @endphp @php $episode = $hentai->episodes[0]; @endphp
<div class="pt-6"> <div class="pt-6">
<div class="flex flex-col lg:flex-row justify-center"> <div class="flex flex-col lg:flex-row justify-center">
<div class="pt-2 sm:px-2 lg:px-4 space-y-6 max-w-[100%] xl:max-w-[70%] 2xl:max-w-[60%] z-10"> <div class="pt-2 sm:px-2 lg:px-4 space-y-6 max-w-[100%] xl:max-w-[70%] 2xl:max-w-[60%] z-10">
@include('series.partials.info') @include('stream.partials.info', ['streamPage' => false])
@include('series.partials.episodes') @include('series.partials.episodes')
@@ -1,12 +1,14 @@
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800 p-5"> <div class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm backdrop-blur-sm transition-colors dark:border-white/10 dark:bg-neutral-900/80">
<a class="leading-normal font-bold text-lg text-rose-600"> <div class="p-5 md:p-7">
{{ __('home.episodes') }} ({{ $hentai->episodes->count() }}) <a class="text-lg font-bold text-gray-900 dark:text-white">
</a> {{ __('home.episodes') }} ({{ $hentai->episodes->count() }})
</a>
<!-- Episode List --> <!-- Episode List -->
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2"> <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2 mt-4">
@foreach ($hentai->episodes as $episode) @foreach ($hentai->episodes as $episode)
<x-episode-cover :episode="$episode" view="thumbnail" /> <x-episode-cover :episode="$episode" view="thumbnail" />
@endforeach @endforeach
</div>
</div> </div>
</div> </div>
@@ -1,69 +0,0 @@
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800 p-5">
<!-- Cover -->
<div class="w-[100px] md:w-[150px] mr-4 float-left">
<img alt="{{ $episode->title }}" loading="lazy" width="150"
class="block relative rounded-lg object-cover object-center aspect-[11/16] z-20"
src="{{ $episode->cover_url }}"></img>
</div>
<!-- Info -->
<div class="relative">
<!-- Title -->
<h1 class="text-3xl font-bold text-rose-600">
{{ $episode->title }} ({{ $episode->title_jpn }})
</h1>
<div>
<a data-te-toggle="tooltip"
title="Released {{ \Carbon\Carbon::parse($episode->release_date)->diffForHumans(['parts' => 2]) }}"
class="text-l text-gray-800 dark:text-white leading-tight pl-1">
<i class="fa-regular fa-calendar"></i> {{ $episode->release_date }}
|
</a>
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
class="text-l text-gray-800 dark:text-white leading-tight hover:underline hover:underline-offset-4">
{{ $episode->studio->name }}
</a>
</div>
<hr class="border-gray-400/40 mt-2 mb-2">
<p class="leading-normal font-bold text-lg text-rose-600">
{{ __('stream.description') }}
</p>
<p class="text-gray-800 dark:text-gray-200 leading-tight min-h-[50%]">
{{ $hentai->description }}
</p>
<hr class="border-gray-400/40 mt-2 mb-1">
<ul class="list-none text-center" style="overflow: hidden;">
<a class="text-gray-400">
|
</a>
@foreach ($episode->tags->sortBy('slug') as $tag)
<li class="inline-block p-1">
@if ($tag->slug == 'uncensored' || $tag->slug == 'vanilla' || $tag->slug == '4k')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-green-500 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-green-700 focus:bg-green-700 active:bg-green-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition ease-in-out duration-150">
{{ $tag->name }}
</a>
@elseif($tag->slug == 'censored')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-yellow-600 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-yellow-700 focus:bg-yellow-700 active:bg-yellow-900 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 transition ease-in-out duration-150">
{{ $tag->name }}
</a>
@elseif($tag->slug == 'gore' || $tag->slug == 'horror' || $tag->slug == 'scat' || $tag->slug == 'ntr' || $tag->slug == 'rape')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-red-600 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-red-700 focus:bg-red-700 active:bg-red-900 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150">
<i class="fa-solid fa-triangle-exclamation"></i> {{ $tag->name }}
</a>
@else
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs dark:text-white hover:text-white uppercase tracking-widest hover:bg-rose-700 focus:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 transition ease-in-out duration-150">
{{ $tag->name }}
</a>
@endif
</li>
<a class="text-gray-400">
|
</a>
@endforeach
</ul>
</div>
</div>
+11 -7
View File
@@ -1,4 +1,9 @@
<x-app-layout> <x-app-layout>
<x-slot name="socialSlot">
@include('partials.social-hentai-preview', ['episode' => $episode])
</x-slot>
<div class="pt-10"> <div class="pt-10">
<div class="flex flex-col xl:flex-row justify-center"> <div class="flex flex-col xl:flex-row justify-center">
@if($episode->is_dvd_aspect) @if($episode->is_dvd_aspect)
@@ -15,7 +20,6 @@
</div> </div>
@endif @endif
@include('admin.stream')
<!-- Infos --> <!-- Infos -->
@include('stream.partials.info') @include('stream.partials.info')
<!-- Comments --> <!-- Comments -->
@@ -38,18 +42,18 @@
@include('modals.add-to-playlist') @include('modals.add-to-playlist')
@include('modals.share') @include('modals.share')
@auth @auth
@if (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR))
@include('admin.modals.edit-episode')
@endif
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
@include('admin.modals.upload-episode') @include('admin.modals.upload-episode')
@include('admin.modals.add-subtitles') @include('admin.modals.add-subtitles')
@endif @endif
@if (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || auth()->user()->hasRole(\App\Enums\UserRole::MODERATOR))
@include('admin.modals.edit-episode')
@endif
@endauth @endauth
<!-- Player Script --> <!-- Player Script -->
@vite(['resources/js/player.js']) @vite(['resources/js/player.js'])
</x-app-layout> </x-app-layout>
@@ -1,58 +1,66 @@
<div class="bg-transparent rounded-lg"> <div>
<div class="px-1 sm:px-2">
<p class="leading-normal font-bold text-lg text-gray-900 dark:text-gray-200"> <div class="mb-5 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ __('stream.gallery') }} {{ __('stream.gallery') }}
</p> </h2>
@if ($gallery->count() > 5)
<button
id="galleryToggle"
class="text-sm font-semibold text-gray-900 dark:text-white transition">
{{ __('home.show-more') }}
</button>
@endif
</div> </div>
@if ($gallery->count() > 5)
<div class="grid grid-rows-1 w-30 text-left"> <div
<ul data-te-lightbox-init class="list-none text-center" style="overflow: hidden;"> data-te-lightbox-init
@php $counter = 0; @endphp class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
@foreach($gallery as $image)
@php $counter++; @endphp @foreach($gallery as $index => $image)
<li class="inline-block m-1 w-[45%] sm:w-[45%] md:w-[20%] xl:w-[18%]">
@if ($counter > 5) <div class="{{ $index >= 5 ? 'hidden extra-gallery-item' : '' }}">
<div class="!visible hidden" id="collapseGallery" data-te-collapse-item>
@else <div
<div> class="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl dark:border-neutral-700 dark:bg-neutral-800">
@endif
<div class="py-2 mt-2"> <img
<img onClick="(function(){player.play(); player.pause(); })();" src="{{ $image->thumbnail_url }}" data-te-img="{{ $image->image_url }}" alt="{{ $episode->title }} - {{ $episode->episode }} - Screenshot {{ $counter }}" class="relative block items-center h-full w-full rounded-lg tracking-widest transition ease-in-out duration-150 cursor-zoom-in shadow-sm data-[te-lightbox-disabled]:cursor-auto" /> onClick="(function(){player.play(); player.pause(); })();"
</div> src="{{ $image->thumbnail_url }}"
</div> data-te-img="{{ $image->image_url }}"
</li> alt="{{ $episode->title }} - Screenshot {{ $index + 1 }}"
@endforeach class="aspect-video w-full cursor-zoom-in object-cover transition duration-300 group-hover:scale-105"
</ul> >
</div>
<div class="grid grid-rows-1 w-30 text-center">
<a id="galleryShowMore" data-te-collapse-init data-te-ripple-init data-te-ripple-color="light" href="#collapseGallery" role="button" aria-expanded="false" aria-controls="collapseGallery" class="text-sm float-right cursor-pointer text-rose-600">{{ __('home.show-more') }}</a>
</div>
<script>
var state = 0;
function toggleGallery() {
if (state == 0) {
document.getElementById('galleryShowMore').innerText = "{{ __('stream.show-less') }}";
state = 1;
} else {
document.getElementById('galleryShowMore').innerText = "{{ __('home.show-more') }}";
state = 0;
}
}
document.getElementById('galleryShowMore').addEventListener('click', toggleGallery);
</script>
@else
<div class="grid grid-rows-1 w-30 text-left">
<ul data-te-lightbox-init class="list-none text-center" style="overflow: hidden;">
@php $counter = 0; @endphp
@foreach($gallery as $image)
@php $counter++; @endphp
<li class="inline-block m-1 w-[45%] sm:w-[45%] md:w-[20%] xl:w-[18%]">
<div class="py-2 mt-2">
<img onClick="(function(){player.play(); player.pause(); })();" src="{{ $image->thumbnail_url }}" data-te-img="{{ $image->image_url }}" alt="{{ $episode->title }} - {{ $episode->episode }} - Screenshot {{ $counter }}" class="relative block items-center h-full w-full rounded-lg tracking-widest transition ease-in-out duration-150 cursor-zoom-in shadow-sm data-[te-lightbox-disabled]:cursor-auto" />
</div> </div>
</li>
@endforeach </div>
</ul>
@endforeach
</div> </div>
@endif
</div> </div>
@if ($gallery->count() > 5)
<script>
const toggleBtn = document.getElementById('galleryToggle');
const hiddenItems = document.querySelectorAll('.extra-gallery-item');
let expanded = false;
toggleBtn.addEventListener('click', () => {
expanded = !expanded;
hiddenItems.forEach(item => {
item.classList.toggle('hidden');
});
toggleBtn.innerText = expanded
? "{{ __('stream.show-less') }}"
: "{{ __('home.show-more') }}";
});
</script>
@endif
+218 -138
View File
@@ -1,167 +1,247 @@
<div class="overflow-hidden p-5 bg-transparent bg-white rounded-xl dark:bg-neutral-800"> @props([
<!-- Cover --> 'streamPage' => true,
<div class="w-[100px] md:w-[150px] mr-4 float-left hidden md:block"> ])
<img alt="{{ $episode->title }}" loading="lazy" width="150"
class="block relative rounded-xl object-cover object-center aspect-[11/16] z-20"
src="{{ $episode->cover_url }}"></img>
</div>
<!-- Info --> <div
<div class="relative"> class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm backdrop-blur-sm transition-colors dark:border-white/10 dark:bg-neutral-900/80">
<div class="p-5 md:p-7">
@if($streamPage)
<input id="e_id" type="hidden" value="{{ $episode->id }}" /> <input id="e_id" type="hidden" value="{{ $episode->id }}" />
<div class="flex flex-col justify-between xl:flex-row"> <input id="auth_check" type="hidden" value="{{ auth()->check() ? '1' : '0' }}" />
<div> @endif
<!-- Title -->
<h1 class="text-3xl font-bold text-rose-600">
<a class="text-rose-600 break-words hover:underline hover:underline-offset-4"
href="{{ route('hentai.index', ['title' => $episode->hentai->slug]) }}">{{ $episode->title }}</a>
- {{ $episode->episode }}
</h1>
<div>
<h2 class="inline leading-tight text-gray-800 dark:text-white">{{ $episode->title_jpn }}</h2>
<a data-te-toggle="tooltip" <div class="flex flex-col gap-6 lg:flex-row">
title="Uploaded {{ $episode->created_at->diffForHumans(['parts' => 2]) }}" <!-- Cover -->
class="pl-1 leading-tight text-gray-800 text-l dark:text-white"> <div class="hidden shrink-0 md:block">
<p class="inline"> <img
| <i class="fa-solid fa-upload"></i> {{ $episode->created_at->format('Y-m-d') }} | alt="{{ $episode->title }}"
</p> loading="lazy"
</a> width="180"
<a data-te-toggle="tooltip" src="{{ $episode->cover_url }}"
title="Released {{ \Carbon\Carbon::parse($episode->release_date)->diffForHumans(['parts' => 2]) }}" class="aspect-[11/16] w-[140px] rounded-2xl object-cover shadow-lg ring-1 ring-black/5 dark:ring-white/10" />
class="pl-1 leading-tight text-gray-800 text-l dark:text-white">
<p class="inline">
<i class="fa-regular fa-calendar"></i> {{ $episode->release_date }}
|
</p>
</a>
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
class="leading-tight text-gray-800 text-l dark:text-white hover:underline hover:underline-offset-4">
{{ $episode->studio->name }}
</a>
<a id="av1-unsupported" data-te-toggle="tooltip"
title="For 1080p and 4k streams we are using the new AV1 codec. Edge users on Windows have to install the AV1 extension pack from the Microsoft Store."
class="hidden leading-tight text-red-800 cursor-pointer text-l dark:text-red-500">
AV1 Unsupported
</a>
</div>
</div> </div>
<div> <!-- Main Content -->
<!-- View Count and Misc Buttons --> <div class="flex-1">
<div class="float-right"> <div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div class="grid">
<div class="flex gap-x-4"> <!-- Title + Metadata -->
<div class="min-w-0">
<h1
class="break-words text-2xl font-black tracking-tight text-gray-900 dark:text-white md:text-4xl">
@if ($streamPage)
<a
href="{{ route('hentai.index', ['title' => $episode->hentai->slug]) }}"
class="bg-gradient-to-r from-rose-500 to-pink-500 bg-clip-text text-transparent transition hover:opacity-80">
{{ "$episode->title - $episode->episode" }}
</a>
@else
<span
class="bg-gradient-to-r from-rose-500 to-pink-500 bg-clip-text text-transparent transition">
{{ $episode->title }}
</span>
@endif
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 md:text-base">
{{ $episode->title_jpn }}
</p>
<!-- Meta Pills -->
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
<div
class="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-gray-700 dark:bg-white/5 dark:text-gray-300">
<i class="fa-solid fa-upload text-xs"></i>
{{ $episode->created_at->format('Y-m-d') }}
</div>
<div
class="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-gray-700 dark:bg-white/5 dark:text-gray-300">
<i class="fa-regular fa-calendar text-xs"></i>
{{ $episode->release_date }}
</div>
<a
href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
class="inline-flex items-center rounded-full bg-rose-100 px-3 py-1 font-medium text-rose-700 transition hover:bg-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20">
{{ $episode->studio->name }}
</a>
<a
id="av1-unsupported"
data-te-toggle="tooltip"
title="For 1080p and 4k streams we are using the new AV1 codec."
class="hidden rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-700 dark:bg-red-500/10 dark:text-red-400">
AV1 Unsupported
</a>
</div>
</div>
@if($streamPage)
<!-- Stats + Actions -->
<div class="flex flex-col gap-3 xl:items-end min-w-[330px]">
<!-- Stats -->
<div class="flex flex-wrap items-center gap-3">
<div
class="inline-flex font-bold items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 dark:bg-white/5 dark:text-gray-200">
<i class="fa-regular fa-eye"></i>
{{ $episode->viewCountFormatted() }}
</div>
@auth @auth
@livewire('view-count', ['episode' => $episode])
@livewire('like-button', ['episode' => $episode]) @livewire('like-button', ['episode' => $episode])
@endauth @endauth
@guest @guest
<div> <div
<a class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200"> data-te-toggle="tooltip"
<i class="pr-0.5 fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} title="Please login to like the episode"
</a> class="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 dark:bg-white/5 dark:text-gray-200">
</div> <i class="fa-regular fa-heart"></i>
<div data-te-toggle="tooltip" title="Please login to like the episode" {{ $episode->likeCount() }}
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200">
<i class="fa-regular fa-heart pr-[4px]"></i> {{ $episode->likeCount() }}
</div> </div>
@endguest @endguest
@php $commentcount = $episode->commentCount(); @endphp @php $commentcount = $episode->commentCount(); @endphp
@if ($commentcount > 0)
<a href="#comments" <a
class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200"> href="#comments"
<i class="pr-0.5 fa-regular fa-comment"></i> {{ $commentcount }} class="inline-flex items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 transition hover:bg-gray-200 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
</a> <i class="fa-regular fa-comment"></i>
@else {{ $commentcount }}
<a href="#comments" data-te-toggle="tooltip" title="Be the first one to comment!" </a>
class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200">
<i class="pr-0.5 fa-regular fa-comment"></i> {{ $commentcount }}
</a>
@endif
</div> </div>
<div> <!-- Action Buttons -->
<div class="flex flex-wrap gap-2">
@if(!$episode->dmca_takedown) @if(!$episode->dmca_takedown)
<a data-te-toggle="modal" data-te-target="#modalDownload" id="reloadCaptchaModal" <a
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200"> data-te-toggle="modal"
<i class="fa-solid fa-download pr-[4px]"></i> {{ __('stream.download') }} data-te-target="#modalDownload"
id="reloadCaptchaModal"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700">
<i class="fa-solid fa-download"></i>
{{ __('stream.download') }}
</a>
@endif
<a
data-te-toggle="modal"
data-te-target="#modalShare"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<i class="fa-solid fa-share"></i>
{{ __('stream.share') }}
</a>
@auth
<a
data-te-toggle="modal"
data-te-target="#modalAddToPlaylist"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<i class="fa-solid fa-square-plus"></i>
{{ __('playlist.playlist') }}
</a>
@endauth
</div>
@if(auth()->check() && (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || auth()->user()->hasRole(\App\Enums\UserRole::MODERATOR)))
<div class="flex flex-wrap flex-row-reverse gap-2">
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<a
data-te-toggle="modal"
data-te-target="#modalAddSubtitles"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<i class="fa-solid fa-plus"></i>
Add Subtitles
</a>
<a
data-te-toggle="modal"
data-te-target="#modalUploadEpisode"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<i class="fa-solid fa-plus"></i>
Add Episode
</a> </a>
@endif @endif
<a data-te-toggle="modal" data-te-target="#modalShare" <a
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200"> data-te-toggle="modal"
<i class="fa-solid fa-share pr-[4px]"></i> {{ __('stream.share') }} data-te-target="#modalEditEpisode"
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<i class="fa-solid fa-pen"></i>
Edit Episode
</a> </a>
</div> </div>
@endif
</div>
@endif
</div>
@auth <!-- Description -->
<div> <div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
<a data-te-toggle="modal" data-te-target="#modalAddToPlaylist" <h2 class="mb-3 text-lg font-bold text-gray-900 dark:text-white">
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200"> {{ __('stream.description') }}
<i class="fa-solid fa-square-plus pr-[6px]"></i> {{ __('playlist.playlist') }} </h2>
</a>
</div> <p class="leading-relaxed text-gray-700 dark:text-gray-300">
@endauth {{ $episode->description }}
</p>
</div>
<!-- Genres -->
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">
{{ __('stream.genres') }}
</h2>
<div class="flex flex-wrap gap-2">
@foreach ($episode->tags->sortBy('slug') as $tag)
@php
$classes = 'bg-gray-100 text-gray-700 hover:bg-rose-600 hover:text-white dark:bg-white/5 dark:text-gray-300 dark:hover:bg-rose-600';
if (in_array($tag->slug, ['uncensored', 'vanilla', '4k'])) {
$classes = 'bg-green-100 text-green-700 hover:bg-green-600 hover:text-white dark:hover:text-white dark:bg-green-500/10 dark:text-green-400 dark:hover:bg-green-600';
}
if ($tag->slug === 'censored') {
$classes = 'bg-yellow-100 text-yellow-700 hover:bg-yellow-500 hover:text-white dark:hover:text-white dark:bg-yellow-500/10 dark:text-yellow-400 dark:hover:bg-yellow-500';
}
if (in_array($tag->slug, ['gore', 'horror', 'scat', 'ntr', 'rape'])) {
$classes = 'bg-red-100 text-red-700 hover:bg-red-600 hover:text-white dark:hover:text-white dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-600';
}
@endphp
<a
href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-xs font-bold uppercase tracking-wide transition {{ $classes }}">
@if(in_array($tag->slug, ['gore', 'horror', 'scat', 'ntr', 'rape']))
<i class="fa-solid fa-triangle-exclamation text-[10px]"></i>
@endif
{{ $tag->name }}
</a>
@endforeach
</div> </div>
</div> </div>
<!-- Gallery -->
@if($streamPage)
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
@include('stream.partials.gallery')
</div>
@endisset
</div> </div>
</div> </div>
<hr class="mt-2 mb-2 border-gray-400/40">
<p class="text-lg font-bold leading-normal text-rose-600">
{{ __('stream.description') }}
</p>
<p class="text-gray-800 dark:text-gray-200 leading-tight min-h-[50%]">
{{ $episode->description }}
</p>
<hr class="mt-2 mb-1 border-gray-400/40">
<p class="text-lg font-bold leading-normal text-rose-600">
{{ __('stream.genres') }}
</p>
<ul class="list-none text-center" style="overflow: hidden;">
<a class="text-gray-400">
|
</a>
@foreach ($episode->tags->sortBy('slug') as $tag)
<li class="inline-block p-1">
@if ($tag->slug == 'uncensored' || $tag->slug == 'vanilla' || $tag->slug == '4k')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-green-500 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-green-700 focus:bg-green-700 active:bg-green-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
{{ $tag->name }}
</a>
@elseif($tag->slug == 'censored')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-yellow-600 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-yellow-700 focus:bg-yellow-700 active:bg-yellow-900 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2">
{{ $tag->name }}
</a>
@elseif(
$tag->slug == 'gore' ||
$tag->slug == 'horror' ||
$tag->slug == 'scat' ||
$tag->slug == 'ntr' ||
$tag->slug == 'rape')
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-red-600 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-red-700 focus:bg-red-700 active:bg-red-900 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
<i class="fa-solid fa-triangle-exclamation"></i> {{ $tag->name }}
</a>
@else
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:text-white hover:text-white hover:bg-rose-700 focus:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2">
{{ $tag->name }}
</a>
@endif
</li>
<a class="text-gray-400">
|
</a>
@endforeach
</ul>
<hr class="mt-2 mb-1 border-gray-400/40">
<div class="inline-block pt-5">
@include('stream.partials.gallery')
</div>
</div> </div>
</div> </div>
@@ -8,6 +8,42 @@
@endif @endif
<canvas id="ambientVideo" class="decoy"></canvas> <canvas id="ambientVideo" class="decoy"></canvas>
<div class="relative w-full aspect-[16/9]"> <div class="relative w-full aspect-[16/9]">
<video id="player" playsinline controls crossorigin class="absolute inset-0 w-full h-full"></video> <div class="player-switcher" id="player-switcher">
<span class="player-switcher__label">Player</span>
<button type="button" class="player-switcher__btn" id="plyr-toggle-btn"
onclick="window.setPlayerPreference('plyr')" title="Switch to Plyr player">
<span class="player-switcher__dot"></span> Plyr
</button>
<button type="button" class="player-switcher__btn player-switcher__btn--active" id="hstream-toggle-btn"
onclick="window.setPlayerPreference('hstream')" title="Switch to HStream player">
<span class="player-switcher__dot"></span> HStream
</button>
</div>
<video id="player" playsinline crossorigin class="absolute inset-0 w-full h-full"></video>
</div> </div>
<script>
(function() {
var pref = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
var hstreamBtn = document.getElementById('hstream-toggle-btn');
var plyrBtn = document.getElementById('plyr-toggle-btn');
if (pref === 'plyr') {
hstreamBtn.classList.remove('player-switcher__btn--active');
plyrBtn.classList.add('player-switcher__btn--active');
}
var switcher = document.getElementById('player-switcher');
var container = switcher.parentElement;
var hideTimer = null;
function showSwitcher() {
switcher.classList.add('player-switcher--visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(function () {
switcher.classList.remove('player-switcher--visible');
}, 3000);
}
container.addEventListener('pointerdown', showSwitcher);
})();
</script>
</div> </div>
+4 -1
View File
@@ -45,7 +45,7 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
// Episode // Episode
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode'); Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
Route::post('/admin/episode/edit', [EpisodeController::class, 'update'])->name('admin.edit');
// Get Tags used for Upload Form // Get Tags used for Upload Form
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags'); Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
@@ -66,4 +66,7 @@ Route::group(['middleware' => ['auth', 'auth.moderator']], function () {
// Get Tags for editing Episode // Get Tags for editing Episode
Route::get('/admin/tags/{episode_id}', [AdminApiController::class, 'getEpisodeTags'])->name('admin.tags.episode'); Route::get('/admin/tags/{episode_id}', [AdminApiController::class, 'getEpisodeTags'])->name('admin.tags.episode');
Route::get('/admin/studio/{episode_id}', [AdminApiController::class, 'getEpisodeStudio'])->name('admin.studio.episode'); Route::get('/admin/studio/{episode_id}', [AdminApiController::class, 'getEpisodeStudio'])->name('admin.studio.episode');
// Edit Episode
Route::post('/admin/episode/edit', [EpisodeController::class, 'update'])->name('admin.episode.edit');
}); });

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