Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c37df755 | |||
| 6a9d3b25bf | |||
| 75b98de746 | |||
| eaf48276f0 | |||
| 2e3918def4 | |||
| 7553b9f895 | |||
| 5af1c3c447 | |||
| 2b1a967065 | |||
| ee1c17b903 | |||
| a5bf2ac245 | |||
| 4f81164b44 | |||
| 859a35847a | |||
| f35d1a119e | |||
| a04d58c60f | |||
| bcd15d8569 | |||
| 133a069890 | |||
| b32465a7d0 | |||
| f8022b3f18 | |||
| 57d1ec34c3 | |||
| 81639aaabf | |||
| 5dc1bff60c | |||
| 2f3f0edc30 | |||
| a71b2976af | |||
| 2c016274ab | |||
| 5ba0a55316 | |||
| a6fe34a0d1 | |||
| bb53e06c69 | |||
| 5cae5dc658 | |||
| 356d07365f | |||
| 3574d20fae | |||
| 9fc9e8ed10 | |||
| f5c706b587 | |||
| cbea71d9ae | |||
| 64a621173c | |||
| 839779b82e | |||
| 112cf9433e | |||
| 26a6500fca | |||
| d8cf70e747 | |||
| 0d4545c2ab | |||
| 900103e1c2 | |||
| d4c90976f8 | |||
| 72263127df | |||
| 6d3de59929 | |||
| ddb1bc2d14 | |||
| 5f3874a233 | |||
| ba3650899e | |||
| 904604fcfb | |||
| b7b34b503c | |||
| 4928733383 | |||
| 6340302ac6 | |||
| c1829ba7bd | |||
| 0b155bbb80 | |||
| 9f959efa14 | |||
| 38e3346dc3 | |||
| 09c08f3fea | |||
| 75f631c3e6 | |||
| fdf26604f3 | |||
| 59cb39ca77 |
@@ -16,7 +16,7 @@ class GenerateSitemap extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sitemap:generate';
|
||||
protected $signature = 'app:generate-sitemap';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\Downloads;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\PopularDaily;
|
||||
use App\Models\PopularMonthly;
|
||||
use App\Models\PopularWeekly;
|
||||
use App\Models\User;
|
||||
use Conner\Tagging\Model\Tag;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -61,6 +63,100 @@ class CacheHelper
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTotalUserCount()
|
||||
{
|
||||
return Cache::remember('total_user_count', now()->addMinutes(60), function () {
|
||||
return User::count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTotalCommentCount()
|
||||
{
|
||||
return Cache::remember('total_comment_count', now()->addMinutes(60), function () {
|
||||
return Comment::count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTotalLikeCount()
|
||||
{
|
||||
return Cache::remember('total_like_count', now()->addMinutes(60), function () {
|
||||
return DB::table('markable_likes')->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTotalDownloadCount()
|
||||
{
|
||||
return Cache::remember('total_download_count', now()->addMinutes(60), function () {
|
||||
return Downloads::sum('count');
|
||||
});
|
||||
}
|
||||
|
||||
public static function get4kEpisodeCount()
|
||||
{
|
||||
return Cache::remember('episodes_4k_count', now()->addMinutes(60), function () {
|
||||
return Episode::where('interpolated', true)->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getUHD48FpsEpisodeCount()
|
||||
{
|
||||
return Cache::remember('episodes_uhd48_count', now()->addMinutes(60), function () {
|
||||
return Episode::where('interpolated_uhd', true)->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTodayViewCount()
|
||||
{
|
||||
return Cache::remember('today_view_count', now()->addMinutes(30), function () {
|
||||
return PopularDaily::whereDate('created_at', today())->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getWeeklyViewCount()
|
||||
{
|
||||
return Cache::remember('weekly_view_count', now()->addMinutes(60), function () {
|
||||
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPreviousWeeklyViewCount()
|
||||
{
|
||||
return Cache::remember('prev_weekly_view_count', now()->addMinutes(60), function () {
|
||||
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(14))
|
||||
->whereDate('created_at', '<', today()->subDays(7))
|
||||
->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getAverageViewsPerEpisode()
|
||||
{
|
||||
return Cache::remember('avg_views_per_episode', now()->addMinutes(60), function () {
|
||||
$totalEpisodes = Episode::count();
|
||||
if ($totalEpisodes === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(Episode::sum('view_count') / $totalEpisodes);
|
||||
});
|
||||
}
|
||||
|
||||
public static function getNewEpisodesThisWeek()
|
||||
{
|
||||
return Cache::remember('new_episodes_this_week', now()->addMinutes(60), function () {
|
||||
return Episode::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTopTags()
|
||||
{
|
||||
return Cache::remember('top_tags_stats', now()->addMinutes(120), function () {
|
||||
return Tag::where('count', '>', 0)
|
||||
->orderBy('count', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPopularAllTime(bool $guest)
|
||||
{
|
||||
$guestString = $guest ? 'guest' : 'authed';
|
||||
@@ -128,7 +224,7 @@ class CacheHelper
|
||||
public static function getLatestComments()
|
||||
{
|
||||
return Cache::remember('latest_comments', now()->addMinutes(60), function () {
|
||||
return Comment::latest()->take(10)->get();
|
||||
return Comment::with('user')->latest()->take(10)->get();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Conner\Tagging\Model\Tag;
|
||||
|
||||
class FilterCategories
|
||||
{
|
||||
public static function getFilterCategories()
|
||||
{
|
||||
$taglist = Cache::remember(
|
||||
'searchtags',
|
||||
300,
|
||||
fn () => Tag::where('count', '>', 0)
|
||||
->orderBy('slug')
|
||||
->get(),
|
||||
);
|
||||
|
||||
$appearances = [
|
||||
'Loli',
|
||||
'Shota',
|
||||
'Milf',
|
||||
'Futanari',
|
||||
'Big Boobs',
|
||||
'Small Boobs',
|
||||
'Dark Skin',
|
||||
'Cosplay',
|
||||
'Elf',
|
||||
'Maid',
|
||||
'Nekomimi',
|
||||
'Nurse',
|
||||
'School Girl',
|
||||
'Succubus',
|
||||
'Teacher',
|
||||
'Trap',
|
||||
'Pregnant',
|
||||
'Glasses',
|
||||
'Swim Suit',
|
||||
'Ugly Bastard',
|
||||
'Monster',
|
||||
];
|
||||
|
||||
$types = [
|
||||
'3D',
|
||||
'4K',
|
||||
'48Fps',
|
||||
'4K 48Fps',
|
||||
'Censored',
|
||||
'Uncensored',
|
||||
'Comedy',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Vanilla',
|
||||
'Ntr',
|
||||
'Pov',
|
||||
'Filmed',
|
||||
'X-Ray',
|
||||
];
|
||||
|
||||
$actions = [
|
||||
'Anal',
|
||||
'Bdsm',
|
||||
'Facial',
|
||||
'Blow Job',
|
||||
'Boob Job',
|
||||
'Foot Job',
|
||||
'Hand Job',
|
||||
'Rimjob',
|
||||
'Inflation',
|
||||
'Masturbation',
|
||||
'Public Sex',
|
||||
'Rape',
|
||||
'Reverse Rape',
|
||||
'Threesome',
|
||||
'Orgy',
|
||||
'Gangbang',
|
||||
];
|
||||
|
||||
$excluded = [...$appearances, ...$types, ...$actions];
|
||||
|
||||
$categories = [
|
||||
'Genres' => $taglist
|
||||
->reject(fn ($tag) => in_array($tag->name, $excluded))
|
||||
->pluck('name')
|
||||
->toArray(),
|
||||
|
||||
'Actions' => $actions,
|
||||
|
||||
'Appearance' => collect($appearances)
|
||||
->reject(function ($tag) {
|
||||
return Auth::guest() && in_array($tag, ['Loli', 'Shota']);
|
||||
})
|
||||
->toArray(),
|
||||
|
||||
'Types' => $types,
|
||||
];
|
||||
|
||||
return $categories;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DiscordReleaseNotification;
|
||||
use App\Models\Episode;
|
||||
@@ -63,6 +64,17 @@ class EpisodeController extends Controller
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$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);
|
||||
|
||||
$oldinterpolated = $episode->interpolated;
|
||||
|
||||
@@ -4,7 +4,14 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Episode;
|
||||
use App\Models\VideoEngagement;
|
||||
use App\Models\Watched;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
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
|
||||
{
|
||||
@@ -34,4 +41,103 @@ class StreamApiController extends Controller
|
||||
'extra_subtitles' => $subtitles,
|
||||
], 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\Log;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\InvalidStateException;
|
||||
|
||||
class DiscordAuthController extends Controller
|
||||
{
|
||||
@@ -26,7 +27,12 @@ class DiscordAuthController extends Controller
|
||||
*/
|
||||
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();
|
||||
|
||||
|
||||
@@ -103,6 +103,18 @@ class HomeController extends Controller
|
||||
'viewCount' => CacheHelper::getTotalViewCount(),
|
||||
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
|
||||
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
|
||||
'userCount' => CacheHelper::getTotalUserCount(),
|
||||
'commentCount' => CacheHelper::getTotalCommentCount(),
|
||||
'likeCount' => CacheHelper::getTotalLikeCount(),
|
||||
'downloadCount' => CacheHelper::getTotalDownloadCount(),
|
||||
'episodes4k' => CacheHelper::get4kEpisodeCount(),
|
||||
'episodesUHD48' => CacheHelper::getUHD48FpsEpisodeCount(),
|
||||
'todayViews' => CacheHelper::getTodayViewCount(),
|
||||
'weeklyViews' => CacheHelper::getWeeklyViewCount(),
|
||||
'prevWeeklyViews' => CacheHelper::getPreviousWeeklyViewCount(),
|
||||
'avgViewsPerEpisode' => CacheHelper::getAverageViewsPerEpisode(),
|
||||
'newEpisodesThisWeek' => CacheHelper::getNewEpisodesThisWeek(),
|
||||
'topTags' => CacheHelper::getTopTags(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,17 @@ class NotificationController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Notifcation
|
||||
* Delete a notification or clear all.
|
||||
*/
|
||||
public function delete(Request $request): RedirectResponse
|
||||
{
|
||||
// Clear all notifications if no specific ID is provided
|
||||
if (! $request->has('id')) {
|
||||
$request->user()->notifications()->delete();
|
||||
|
||||
return redirect()->back()->with('status', 'notifications-cleared');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'id' => 'required|exists:notifications,id',
|
||||
]);
|
||||
|
||||
@@ -95,6 +95,34 @@ class PlaylistController extends Controller
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -8,10 +8,8 @@ use App\Models\Gallery;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistEpisode;
|
||||
use App\Models\Watched;
|
||||
use hisorange\BrowserDetect\Facade as Browser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -52,18 +50,6 @@ class StreamController extends Controller
|
||||
// Increment Popular Count
|
||||
$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
|
||||
$isMobile = Browser::isMobile();
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@ class IsModerator
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && Auth::user()->hasRole(UserRole::MODERATOR)) {
|
||||
if (Auth::check() && (
|
||||
Auth::user()->hasRole(UserRole::MODERATOR) ||
|
||||
Auth::user()->hasRole(UserRole::ADMINISTRATOR))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
session()->flash('error_msg', 'This resource is restricted to Administrators!');
|
||||
session()->flash('error_msg', 'This resource is restricted to Moderators!');
|
||||
|
||||
return redirect()->route('home.index');
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ class SetLocale
|
||||
}
|
||||
|
||||
// 2. Session (guest or user override)
|
||||
if (session()->has('locale') && in_array($request->language, config('app.supported_locales'), true)) {
|
||||
if ($request->session()->has('locale') &&
|
||||
in_array(session('locale'), config('app.supported_locales'), true)) {
|
||||
|
||||
App::setLocale(session('locale'));
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -10,37 +13,147 @@ class AdminCommentSearch extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url(history: true)]
|
||||
public $search = '';
|
||||
|
||||
#[Url(history: true)]
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public function deleteComment($commentId)
|
||||
public function deleteComment(int $commentId): void
|
||||
{
|
||||
$comment = Comment::where('id', (int) $commentId)->firstOrFail();
|
||||
$comment = Comment::findOrFail($commentId);
|
||||
$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();
|
||||
$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()
|
||||
{
|
||||
$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', [
|
||||
'comments' => $comments,
|
||||
'comments' => $this->comments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,32 +20,239 @@ class AdminUserSearch extends Component
|
||||
public $discordId = '';
|
||||
|
||||
#[Url(history: true)]
|
||||
public $patreon = [];
|
||||
public $email = '';
|
||||
|
||||
#[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)
|
||||
->firstOrFail();
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
Comment::where('user_id', $user->id)
|
||||
->delete();
|
||||
public function updatedPage(): void
|
||||
{
|
||||
$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();
|
||||
$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()
|
||||
{
|
||||
$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', [
|
||||
'users' => $users,
|
||||
'users' => $this->users,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Episode;
|
||||
use App\Models\ModLog;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CommentNotification;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -43,7 +45,7 @@ class Comment extends Component
|
||||
'replyState.body' => 'reply',
|
||||
];
|
||||
|
||||
public function updatedIsEditing($isEditing)
|
||||
public function updatedIsEditing(bool $isEditing)
|
||||
{
|
||||
if (! $isEditing) {
|
||||
return;
|
||||
@@ -67,11 +69,45 @@ class Comment extends Component
|
||||
{
|
||||
$this->authorize('destroy', $this->comment);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
|
||||
// Log to ModLog
|
||||
ModLog::create([
|
||||
'moderator' => $user->name,
|
||||
'data' => "Deleted comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
|
||||
]);
|
||||
|
||||
$this->comment->deleted_by_moderator_id = $user->id;
|
||||
$this->comment->save();
|
||||
$this->dispatch('refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->comment->delete();
|
||||
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
|
||||
public function restoreComment()
|
||||
{
|
||||
$this->authorize('restore', $this->comment);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
|
||||
// Log to ModLog
|
||||
ModLog::create([
|
||||
'moderator' => $user->name,
|
||||
'data' => "Restored comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
|
||||
]);
|
||||
|
||||
$this->comment->deleted_by_moderator_id = null;
|
||||
$this->comment->save();
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function postReply()
|
||||
{
|
||||
if (! ($this->comment->depth() < 2)) {
|
||||
|
||||
@@ -23,11 +23,21 @@ class DownloadButton extends Component
|
||||
|
||||
public $fileExtension = 'HEVC';
|
||||
|
||||
public $version = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (str_contains($this->downloadUrl, '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)
|
||||
|
||||
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
|
||||
if ($this->navSearch != '') {
|
||||
$episodes = Episode::search($this->navSearch)
|
||||
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
|
||||
->query(fn ($query) => $query->with(['gallery', 'studio']))
|
||||
->take(7)
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ class PlaylistOverview extends Component
|
||||
|
||||
public Collection $playlistEpisodes;
|
||||
|
||||
public bool $editingName = false;
|
||||
|
||||
public string $editingPlaylistName = '';
|
||||
|
||||
public function boot(PlaylistService $playlistService)
|
||||
{
|
||||
$this->playlistService = $playlistService;
|
||||
@@ -112,6 +116,53 @@ class PlaylistOverview extends Component
|
||||
$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()
|
||||
{
|
||||
return view('livewire.playlist-overview', [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,12 @@ class Comment extends Model
|
||||
{
|
||||
return cache()->remember('commentLikes'.$this->id, 300, fn () => $this->likes->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns wether or not comment has been removed by moderation
|
||||
*/
|
||||
public function isDeletedByModerator(): bool
|
||||
{
|
||||
return $this->deleted_by_moderator_id !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ModLog extends Model
|
||||
{
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'moderator',
|
||||
'data',
|
||||
];
|
||||
}
|
||||
@@ -7,6 +7,26 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
+5
-3
@@ -31,7 +31,6 @@ class User extends Authenticatable implements HasPasskeys
|
||||
// Discord
|
||||
'discord_id',
|
||||
'discord_avatar',
|
||||
'subscription_key',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -42,7 +41,6 @@ class User extends Authenticatable implements HasPasskeys
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'subscription_key',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -157,7 +155,11 @@ class User extends Authenticatable implements HasPasskeys
|
||||
return;
|
||||
}
|
||||
|
||||
$this->roles = array_diff($this->roles, [$role->value]);
|
||||
$this->roles = collect($this->roles)
|
||||
->reject(fn ($value) => $value === $role->value)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
@@ -17,6 +18,26 @@ class CommentPolicy
|
||||
|
||||
public function destroy(User $user, Comment $comment): bool
|
||||
{
|
||||
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
|
||||
$user->hasRole(UserRole::MODERATOR)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->id === $comment->user_id;
|
||||
}
|
||||
|
||||
public function restore(User $user, Comment $comment): bool
|
||||
{
|
||||
// Comment not deleted
|
||||
if ($comment->deleted_by_moderator_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
|
||||
$user->hasRole(UserRole::MODERATOR)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Studios;
|
||||
use App\Models\ModLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -62,6 +63,68 @@ class EpisodeService
|
||||
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
|
||||
{
|
||||
$episode = Episode::where('id', $episodeId)->firstOrFail();
|
||||
@@ -75,17 +138,31 @@ class EpisodeService
|
||||
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
|
||||
$episode->save();
|
||||
|
||||
// Tagging
|
||||
$tags = json_decode($request->input('tags'));
|
||||
$newtags = [];
|
||||
foreach ($tags as $t) {
|
||||
$newtags[] = $t->value;
|
||||
}
|
||||
$episode->retag($newtags);
|
||||
$this->applyTags($request, $episode);
|
||||
$this->updateTitle($request, $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
|
||||
{
|
||||
return Studios::firstOrCreate(
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -54,9 +54,4 @@ return [
|
||||
'server' => env('MATRIX_SERVER'),
|
||||
'shared_secret' => env('MATRIX_SHARED_SECRET'),
|
||||
],
|
||||
|
||||
/**
|
||||
* Subscription Service
|
||||
*/
|
||||
'subscription_service_host' => env('SUBSCRIPTION_SERVICE_HOST'),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('comments', function (Blueprint $table) {
|
||||
$table->bigInteger('deleted_by_moderator_id')
|
||||
->nullable()
|
||||
->after('parent_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->dropColumn('deleted_by_moderator_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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('mod_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('moderator');
|
||||
$table->text('data');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mod_logs');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Generated
+470
-488
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,7 @@
|
||||
"chart.js": "^4.5.0",
|
||||
"dashjs": "^5.0.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr": "^3.8.4",
|
||||
"tw-elements": "^1.1.0",
|
||||
"vidstack": "^1.12.13"
|
||||
}
|
||||
|
||||
+33
-48
@@ -1,4 +1,5 @@
|
||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||
@import './player.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -8,29 +9,6 @@
|
||||
--breakpoint-xs: 30rem;
|
||||
}
|
||||
|
||||
/* Player */
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded="true"] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Player Ambient */
|
||||
.decoy {
|
||||
position: absolute;
|
||||
@@ -50,31 +28,6 @@ input:checked~.dot {
|
||||
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 */
|
||||
.hover\:glow:hover {
|
||||
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
|
||||
@@ -128,3 +81,35 @@ input:checked~.dot {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Stats Page - Shimmer Skeleton Loader */
|
||||
.shimmer-overlay {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .shimmer-overlay {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
/* ============================================================
|
||||
HStream Custom Video Player
|
||||
Dark-theme-first with rose-red accents (#c61e54)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Player Wrapper ---- */
|
||||
.hstream-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
direction: ltr;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hstream-player * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- Video Element ---- */
|
||||
.hstream-player__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* ---- Poster Overlay ---- */
|
||||
.hstream-player__poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster--hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ---- Large Play Overlay ---- */
|
||||
.hstream-player__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__play-overlay--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(198, 30, 84, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.15s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 0 30px rgba(198, 30, 84, 0.4), 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(225, 29, 72, 0.9);
|
||||
box-shadow: 0 0 40px rgba(198, 30, 84, 0.6), 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ---- Controls Container ---- */
|
||||
.hstream-player__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: linear-gradient(to top, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.75) 60%, transparent);
|
||||
padding: 40px 12px 10px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.hstream-player__controls--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Progress Section (full width row) ---- */
|
||||
.hstream-player__progress-wrapper {
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: height 0.15s ease, margin-top 0.15s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover,
|
||||
.hstream-player__progress--dragging {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hstream-player__progress--dragging .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.hstream-player__progress-buffer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transition: width 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hstream-player__progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(to right, #c61e54, #e11d48);
|
||||
transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #c61e54;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.15s ease;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(198, 30, 84, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover .hstream-player__progress-thumb,
|
||||
.hstream-player__progress-wrapper--active .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* ---- Time Tooltip ---- */
|
||||
.hstream-player__time-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
color: #fff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hstream-player__time-tooltip--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Thumbnail Preview ---- */
|
||||
.hstream-player__thumbnail-preview {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 16px);
|
||||
transform: translateX(-50%);
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-img {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-time {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.hstream-player__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hstream-player__button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.hstream-player__button--active {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ---- Time Display ---- */
|
||||
.hstream-player__time-display {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.hstream-player__time-separator {
|
||||
opacity: 0.5;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* ---- Volume Wrapper ---- */
|
||||
.hstream-player__volume-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.hstream-player__volume-wrapper:hover .hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.hstream-player--mobile .hstream-player__volume-slider-container {
|
||||
width: 80px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
outline: none !important;
|
||||
cursor: pointer !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
margin-top: -5px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
/* ---- Spacer ---- */
|
||||
.hstream-player__spacer {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
/* ---- Settings Menu ---- */
|
||||
.hstream-player__menu-container {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 80px;
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
background: rgba(20, 20, 20, 0.70);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__menu-container--open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel--active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item-radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio {
|
||||
border-color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-value {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item > span:first-of-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-value {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-badge {
|
||||
display: inline-block;
|
||||
background: #c61e54;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hstream-player__menu-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ---- Skip Overlay (Mobile) ---- */
|
||||
.hstream-player__skip-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(198, 30, 84, 0.7);
|
||||
border-radius: 50%;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay--visible {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ---- Loading Spinner ---- */
|
||||
.hstream-player__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__loading--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: #c61e54;
|
||||
border-radius: 50%;
|
||||
animation: hstream-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hstream-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- Player Switcher Toggle ---- */
|
||||
.player-switcher {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(10, 10, 10, 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
padding: 6px 4px 6px 14px;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.aspect-\[16\/9\]:hover .player-switcher {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.player-switcher--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.player-switcher__label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-switcher__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-switcher__btn:hover {
|
||||
background: rgba(198, 30, 84, 0.2);
|
||||
border-color: rgba(198, 30, 84, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__btn i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.player-switcher__btn--active {
|
||||
background: rgba(198, 30, 84, 0.25);
|
||||
border-color: rgba(198, 30, 84, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
box-shadow: 0 0 6px rgba(198, 30, 84, 0.6);
|
||||
}
|
||||
|
||||
/* ---- Fullscreen (remove rounded borders) ---- */
|
||||
.hstream-player:fullscreen,
|
||||
.hstream-player:-webkit-full-screen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player:fullscreen .hstream-player__video,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__video,
|
||||
.hstream-player:fullscreen .hstream-player__poster,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__poster,
|
||||
.hstream-player:fullscreen .hstream-player__controls,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__controls {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- Idle State (hide controls & cursor) ---- */
|
||||
.hstream-player--idle {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.hstream-player--idle .hstream-player__controls {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Mobile Responsive ---- */
|
||||
@media (max-width: 767px) {
|
||||
.hstream-player {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__controls {
|
||||
padding: 36px 4px 8px 4px;
|
||||
gap: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-container {
|
||||
right: 8px;
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-wrapper {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.hstream-player__time-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Plyr Player Overrides — pink accent, rounded corners, skip overlay
|
||||
============================================================ */
|
||||
.plyr--full-ui input[type='range'] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded='true'] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Plyr Engagement Heatmap, overlays the progress bar track */
|
||||
.plyr__progress__heatmap {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 40%;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.plyr__progress__heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Plyr Mobile Double-tap Skip Overlay */
|
||||
#plyr__time_skip {
|
||||
background: #c61e54;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
left: 50%;
|
||||
min-width: 60px;
|
||||
width: min-content;
|
||||
max-width: 100px;
|
||||
max-height: 90px;
|
||||
opacity: 0;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
transform: translate(-50%, -50%);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transition: 1s;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-shadow: 0px 0px 45px #000000;
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
if (document.getElementById("playlist-add")) {
|
||||
function createPlaylist() {
|
||||
console.log('Adding to Playlist: ' + document.querySelector("#playlist").value)
|
||||
|
||||
function addToPlaylist() {
|
||||
window.axios.post('/hentai/add-to-playlist', {
|
||||
playlist: document.getElementById('playlist').value,
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
document.getElementById("playlist-cancel").click();
|
||||
|
||||
if (response.data.message == 'already-added') {
|
||||
Swal.fire({
|
||||
title: "Already added!",
|
||||
@@ -18,6 +14,8 @@ if (document.getElementById("playlist-add")) {
|
||||
}
|
||||
|
||||
if (response.data.message == 'success') {
|
||||
document.getElementById("playlist-cancel").click();
|
||||
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: "Added episode to the playlist!",
|
||||
@@ -27,33 +25,66 @@ if (document.getElementById("playlist-add")) {
|
||||
}
|
||||
}).catch(function (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")) {
|
||||
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', {
|
||||
name: document.getElementById('name').value,
|
||||
visiblity: document.getElementById('visiblity').value
|
||||
name: nameField.value,
|
||||
visiblity: visibilityField.value
|
||||
}).then(function (response) {
|
||||
window.axios.post('/hentai/add-to-playlist', {
|
||||
playlist: response.data.playlist_id,
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
document.getElementById("playlist-cancel").click();
|
||||
}).then(function (addResponse) {
|
||||
if (addResponse.status == 200) {
|
||||
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) {
|
||||
console.log(error);
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "Could not add episode to the new playlist.",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
|
||||
}).catch(function (error) {
|
||||
console.log(error);
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "Could not create playlist.",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector("#playlist-create-and-add").addEventListener("click", createAndAddPlaylist);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Engagement heatmap tracking
|
||||
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||
// Only tracks segment >= 1 (excludes 0-10s).
|
||||
// Only calls the endpoint when the user is logged in.
|
||||
|
||||
let engagementInterval;
|
||||
let engagementSegments = new Set();
|
||||
let engagementReportInterval;
|
||||
const SEGMENT_DURATION = 10; // seconds per segment
|
||||
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||
|
||||
function isAuthenticated() {
|
||||
const el = document.getElementById('auth_check');
|
||||
return el && el.value === '1';
|
||||
}
|
||||
|
||||
function sendEngagement(episodeId, segments) {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
window.axios.post('/player/engagement', {
|
||||
episode_id: episodeId,
|
||||
segments: segments,
|
||||
}).catch(() => {
|
||||
// Fire-and-forget: silently ignore network errors
|
||||
});
|
||||
}
|
||||
|
||||
export function startEngagementTracking(episodeId) {
|
||||
engagementSegments.clear();
|
||||
|
||||
// Sample current time while playing
|
||||
engagementInterval = setInterval(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (!video || video.paused) return;
|
||||
|
||||
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||
// Skip segment 0 (0-10s) — no need to track the very start
|
||||
if (segment >= 1) {
|
||||
engagementSegments.add(segment);
|
||||
}
|
||||
}, SAMPLE_INTERVAL);
|
||||
|
||||
// Batch report to server
|
||||
engagementReportInterval = setInterval(() => {
|
||||
if (engagementSegments.size === 0) return;
|
||||
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
|
||||
sendEngagement(episodeId, segments);
|
||||
}, REPORT_INTERVAL);
|
||||
|
||||
// Flush remaining segments & cleanup on page unload
|
||||
const cleanup = () => {
|
||||
clearInterval(engagementInterval);
|
||||
clearInterval(engagementReportInterval);
|
||||
|
||||
if (engagementSegments.size > 0) {
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
sendEngagement(episodeId, segments);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
|
||||
export function stopEngagementTracking() {
|
||||
if (engagementInterval) clearInterval(engagementInterval);
|
||||
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,28 +1,17 @@
|
||||
// Plyr Player
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
// Vidstack Player
|
||||
// HStream Custom Video Player
|
||||
import 'vidstack/player/styles/default/theme.css';
|
||||
import 'vidstack/player/styles/default/layouts/video.css';
|
||||
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
|
||||
|
||||
// Dash Support
|
||||
import * as dashjs from 'dashjs';
|
||||
|
||||
// Subtitle Support
|
||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||
|
||||
// Custom JS
|
||||
import { initMobileWidescreen } from './player-mobile';
|
||||
import { mobileDoubleClick } from './player-mobile'
|
||||
import { HStreamPlayer } from './player/player-core';
|
||||
import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/player-mobile';
|
||||
import { playNextPlaylistVideo } from './playlist';
|
||||
import { addVideoTracks } from './player-data';
|
||||
import { addSubtitleTracks } from './player-data';
|
||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||
import { addVideoTracks, addSubtitleTracks } from './player/player-data';
|
||||
import { isIOS } from './detect-ios';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||
import { renderHeatmap } from './player/player-heatmap';
|
||||
|
||||
// Variables
|
||||
var player = null;
|
||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||
var dashSupported = dashjs.supportsMediaSource();
|
||||
@@ -33,124 +22,48 @@ var captions = true;
|
||||
var lastTime = 0.0;
|
||||
var streamServer = '';
|
||||
var streamServers = [];
|
||||
var fallbackServers = [];
|
||||
var streamServerIndex = 0;
|
||||
var streamServerCount = 0;
|
||||
var ambientMode = true;
|
||||
var serverFallback = false;
|
||||
var saveInterval;
|
||||
|
||||
var watchTracked = false;
|
||||
var subtitleInstance = null;
|
||||
|
||||
var controls = [
|
||||
'play-large', // The large play button in the center
|
||||
'play', // Play/pause playback
|
||||
'progress', // The progress bar and scrubber for playback and buffering
|
||||
'current-time', // The current time of playback
|
||||
'duration', // The full duration of the media
|
||||
'mute', // Toggle mute
|
||||
'volume', // Volume control
|
||||
'captions', // Toggle captions
|
||||
'settings', // Settings menu
|
||||
'fullscreen', // Toggle fullscreen
|
||||
];
|
||||
function trackWatchTime() {
|
||||
if (watchTracked) return;
|
||||
var videoEl = document.getElementsByTagName('video')[0];
|
||||
if (videoEl && videoEl.currentTime >= 10) {
|
||||
watchTracked = true;
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
window.axios.post('/watched/track', {
|
||||
episode_id: episodeId
|
||||
}).then(function () {
|
||||
console.log('Watch tracked for episode ' + episodeId);
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to track watch: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load Volume from LocalStorage
|
||||
if (localStorage.hstreamVolume) {
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume'));
|
||||
if (!isNaN(volume)) volume = Math.max(0, Math.min(1, volume));
|
||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||
}
|
||||
|
||||
// Load Captions from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||
captions = (localStorage.getItem('hstreamCaptions') === 'true');
|
||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||
}
|
||||
|
||||
// Load Muted from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||
if (localStorage.hstreamMuted) {
|
||||
muted = (localStorage.getItem('hstreamMuted') === 'true');
|
||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||
}
|
||||
|
||||
// Asia Server Fallback
|
||||
if (localStorage.hstreamServerFallback) {
|
||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||
}
|
||||
|
||||
// Alert User when AV1 is not supported
|
||||
if (!av1Supported) {
|
||||
document.getElementById("av1-unsupported").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function initDash(data, player) {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(video, el.src, true);
|
||||
// Expose player and dash so they can be used from the console
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCanvasDimension(canvas, video) {
|
||||
canvas.height = video.offsetHeight;
|
||||
canvas.width = video.offsetWidth;
|
||||
}
|
||||
|
||||
function paintStaticVideo(ctx, video) {
|
||||
if (localStorage.theme == 'light') {
|
||||
return;
|
||||
}
|
||||
if (!ambientMode) {
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||
}
|
||||
|
||||
function toggleAmbientMode() {
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
if (ambientMode) {
|
||||
ambientMode = false;
|
||||
localStorage.ambientMode = 'false';
|
||||
setCanvasDimension(canvas, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||
} else {
|
||||
ambientMode = true;
|
||||
localStorage.ambientMode = 'true';
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAsiaServer() {
|
||||
if (serverFallback) {
|
||||
serverFallback = false;
|
||||
localStorage.hstreamServerFallback = 'false';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||
streamServers = apiResponse.stream_domains;
|
||||
} else {
|
||||
serverFallback = true;
|
||||
localStorage.hstreamServerFallback = 'true';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function initSubtitles(lang) {
|
||||
@@ -158,32 +71,28 @@ function initSubtitles(lang) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispose old instance
|
||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
subtitleInstance.dispose();
|
||||
}
|
||||
|
||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
|
||||
if (lang != 'en') {
|
||||
if (lang !== 'en') {
|
||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||
}
|
||||
else {
|
||||
newSubUrl += 'eng.ass'
|
||||
} else {
|
||||
newSubUrl += 'eng.ass';
|
||||
}
|
||||
|
||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
// Hindi font
|
||||
if (lang == 'hi') {
|
||||
var subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
if (lang === 'hi') {
|
||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
var options = {
|
||||
video: document.getElementsByTagName('video')[0], // HTML5 video element
|
||||
subUrl: newSubUrl, // Link to subtitles
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js"
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker
|
||||
video: document.getElementsByTagName('video')[0],
|
||||
subUrl: newSubUrl,
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||
fonts: [subFont],
|
||||
renderMode: 'wasm-blend',
|
||||
};
|
||||
@@ -191,215 +100,154 @@ function initSubtitles(lang) {
|
||||
subtitleInstance = new SubtitlesOctopus(options);
|
||||
}
|
||||
|
||||
function initPlayerQualityChange(data) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', function () {
|
||||
initDash(data);
|
||||
});
|
||||
initDash(data);
|
||||
}
|
||||
}
|
||||
|
||||
function initDash(data) {
|
||||
var videoEl = document.querySelector('video');
|
||||
var quality = player.quality;
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === quality) {
|
||||
var dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(videoEl, el.src, true);
|
||||
window.dash = dash;
|
||||
player.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
player = new Plyr('#player', {
|
||||
controls,
|
||||
quality: {
|
||||
default: 720,
|
||||
options: [2161, 2160, 1081, 1080, 720]
|
||||
var videoEl = document.querySelector('#player');
|
||||
var container = videoEl.parentElement;
|
||||
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
|
||||
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
|
||||
player = new HStreamPlayer({
|
||||
container: container,
|
||||
video: videoEl,
|
||||
apiResponse: apiResponse,
|
||||
streamServer: streamServer,
|
||||
streamServers: streamServers,
|
||||
fallbackServers: fallbackServers,
|
||||
streamServerIndex: streamServerIndex,
|
||||
av1Supported: av1Supported,
|
||||
dashSupported: dashSupported,
|
||||
poster: apiResponse.poster,
|
||||
title: apiResponse.title,
|
||||
data: data,
|
||||
subtitleTracks: subtitleTracks,
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
captionsActive: captions,
|
||||
captionLanguage: 'en',
|
||||
ambientMode: ambientMode,
|
||||
isMobile: isMobile(),
|
||||
quality: parseInt(localStorage.getItem('hstreamQuality')) || 1080,
|
||||
lastTime: lastTime,
|
||||
subtitleInstance: subtitleInstance,
|
||||
onEnded: function () {
|
||||
playNextPlaylistVideo();
|
||||
},
|
||||
i18n: {
|
||||
qualityLabel: {
|
||||
2161: "2160p48",
|
||||
2160: "2160p",
|
||||
1081: "1080p48",
|
||||
1080: "1080p",
|
||||
720: "720p"
|
||||
},
|
||||
qualityBadge: {
|
||||
2161: "UHD@48",
|
||||
1081: "FHD@48",
|
||||
1080: "FHD",
|
||||
},
|
||||
onTimeUpdate: function () {
|
||||
trackWatchTime();
|
||||
},
|
||||
onQualityChange: function (size) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
initDash(data);
|
||||
}
|
||||
},
|
||||
onVolumeChange: function () {
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||
},
|
||||
onCaptionsToggle: function (active) {
|
||||
localStorage.setItem('hstreamCaptions', active.toString());
|
||||
if (subtitleInstance && subtitleInstance.canvas) {
|
||||
subtitleInstance.canvas.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
var libassParent = document.querySelector('.libassjs-canvas-parent');
|
||||
if (libassParent) {
|
||||
libassParent.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
},
|
||||
onLanguageChange: function (lang) {
|
||||
initSubtitles(lang);
|
||||
if (player) {
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
},
|
||||
onServerChange: function (index) {
|
||||
streamServerIndex = index;
|
||||
var allServers = streamServers.concat(fallbackServers);
|
||||
streamServer = allServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
},
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||
});
|
||||
|
||||
// Player Track Data
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
window.player = player;
|
||||
|
||||
player.source = {
|
||||
type: 'video',
|
||||
title: apiResponse.title,
|
||||
poster: apiResponse.poster,
|
||||
previewThumbnails: {
|
||||
enabled: true,
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||
},
|
||||
sources: data,
|
||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||
};
|
||||
if (player.captionsActive) {
|
||||
initSubtitles(player.captionLanguage);
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
|
||||
player.volume = volume;
|
||||
player.muted = muted;
|
||||
//player.captions.languages = ['en'];
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
if (!isMobile()) {
|
||||
player.initThumbnails(vttThumbsUrl);
|
||||
}
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
});
|
||||
|
||||
initDash(data, player);
|
||||
initDash(data);
|
||||
}
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
initMobileWidescreen(container, videoEl);
|
||||
initMobileDoubleTap(container, videoEl, player);
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
player.initHeatmap(episodeId);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
// Server select (Asia)
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log("Stopped video, because user didn't click play.")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})();
|
||||
videoEl.addEventListener('play', function onFirstPlay() {
|
||||
videoEl.removeEventListener('play', onFirstPlay);
|
||||
startEngagementTracking(episodeId);
|
||||
});
|
||||
|
||||
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 () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const time = parseInt(params.get("t"));
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var time = parseInt(params.get('t'));
|
||||
if (!isNaN(time)) {
|
||||
player.currentTime = time;
|
||||
console.log("Skipping to " + time)
|
||||
console.log('Skipping to ' + time);
|
||||
}
|
||||
if (lastTime > 0) {
|
||||
player.currentTime = lastTime;
|
||||
console.log("Skipping to " + lastTime)
|
||||
console.log('Skipping to ' + lastTime);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
player.on('ready', () => {
|
||||
mobileDoubleClick(player);
|
||||
});
|
||||
|
||||
// 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 () {
|
||||
lastTime = player.currentTime;
|
||||
console.log("Last Player Position: " + lastTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
async function initVidstackPlayer() {
|
||||
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
|
||||
player = await VidstackPlayer.create({
|
||||
target: '#player',
|
||||
@@ -421,52 +269,56 @@ async function initVidstackPlayer() {
|
||||
]
|
||||
});
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
window.player = player;
|
||||
|
||||
player.addEventListener('play', () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
player.addEventListener('time-update', function () {
|
||||
trackWatchTime();
|
||||
});
|
||||
}
|
||||
|
||||
// Get Data from API
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
window.setPlayerPreference = function(pref) {
|
||||
localStorage.setItem('hstreamPlayerPreference', pref);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
|
||||
if (playerPreference === 'plyr' && !isIOS()) {
|
||||
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
|
||||
} else {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status === 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains || [];
|
||||
fallbackServers = apiResponse.asia_stream_domains || [];
|
||||
|
||||
const cdnCount = streamServers.length;
|
||||
if (cdnCount > 0) {
|
||||
streamServerIndex = Math.floor(Math.random() * cdnCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
} else {
|
||||
const fallbackCount = fallbackServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * fallbackCount);
|
||||
streamServer = fallbackServers[streamServerIndex];
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length + fallbackServers.length;
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
} else {
|
||||
console.log('Detected Apple device. Using Vidstack fallback player.');
|
||||
initVidstackPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
console.log("Detected Apple Shit. Using different player.")
|
||||
initVidstackPlayer();
|
||||
}
|
||||
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById("player-alert");
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
|
||||
if (dashSupported) {
|
||||
return addDashTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
return addLegacyTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
|
||||
function addDashTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
|
||||
size: 720,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (av1Supported) {
|
||||
// 1080p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
|
||||
size: 1080,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
// 2160p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
|
||||
size: 2160,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (apiResponse.interpolated == 1) {
|
||||
// 1080p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
|
||||
size: 1081,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
|
||||
if (apiResponse.interpolated_uhd == 1) {
|
||||
// 2160p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
|
||||
size: 2161,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
|
||||
type: 'video/mp4',
|
||||
size: 720,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
export function addSubtitleTracks(streamServer, apiResponse) {
|
||||
var data = [];
|
||||
|
||||
// Default
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: 'English',
|
||||
srclang: 'en',
|
||||
src: '',
|
||||
default: true,
|
||||
});
|
||||
|
||||
for (var key in apiResponse.extra_subtitles) {
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
|
||||
srclang: key,
|
||||
src: '',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Engagement heatmap tracking
|
||||
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||
// Only tracks segment >= 1 (excludes 0-10s).
|
||||
// Only calls the endpoint when the user is logged in.
|
||||
|
||||
let engagementInterval;
|
||||
let engagementSegments = new Set();
|
||||
let engagementReportInterval;
|
||||
const SEGMENT_DURATION = 10; // seconds per segment
|
||||
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||
|
||||
function isAuthenticated() {
|
||||
const el = document.getElementById('auth_check');
|
||||
return el && el.value === '1';
|
||||
}
|
||||
|
||||
function sendEngagement(episodeId, segments) {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
window.axios.post('/player/engagement', {
|
||||
episode_id: episodeId,
|
||||
segments: segments,
|
||||
}).catch(() => {
|
||||
// Fire-and-forget: silently ignore network errors
|
||||
});
|
||||
}
|
||||
|
||||
export function startEngagementTracking(episodeId) {
|
||||
engagementSegments.clear();
|
||||
|
||||
// Sample current time while playing
|
||||
engagementInterval = setInterval(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (!video || video.paused) return;
|
||||
|
||||
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||
// Skip segment 0 (0-10s) — no need to track the very start
|
||||
if (segment >= 1) {
|
||||
engagementSegments.add(segment);
|
||||
}
|
||||
}, SAMPLE_INTERVAL);
|
||||
|
||||
// Batch report to server
|
||||
engagementReportInterval = setInterval(() => {
|
||||
if (engagementSegments.size === 0) return;
|
||||
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
|
||||
sendEngagement(episodeId, segments);
|
||||
}, REPORT_INTERVAL);
|
||||
|
||||
// Flush remaining segments & cleanup on page unload
|
||||
const cleanup = () => {
|
||||
clearInterval(engagementInterval);
|
||||
clearInterval(engagementReportInterval);
|
||||
|
||||
if (engagementSegments.size > 0) {
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
sendEngagement(episodeId, segments);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
|
||||
export function stopEngagementTracking() {
|
||||
if (engagementInterval) clearInterval(engagementInterval);
|
||||
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Engagement heatmap display
|
||||
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
|
||||
|
||||
let heatmapContainer = null;
|
||||
let heatmapCanvas = null;
|
||||
let heatmapResizeObserver = null;
|
||||
|
||||
/**
|
||||
* Fetch engagement data from the server and render the heatmap.
|
||||
* @param {string} episodeId - The episode ID.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
export async function renderHeatmap(episodeId, duration) {
|
||||
try {
|
||||
const response = await window.axios.get(`/player/engagement/${episodeId}`);
|
||||
const data = response.data;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawHeatmapCurve(data, duration);
|
||||
} catch (error) {
|
||||
console.error('Failed to load engagement data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a smooth area chart on a canvas element above the progress bar.
|
||||
* @param {Object} data - Key-value map of segment -> watch_count.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
function drawHeatmapCurve(data, duration) {
|
||||
const SEGMENT_DURATION = 10;
|
||||
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
|
||||
|
||||
// Build raw counts array, filling gaps with 0
|
||||
const rawCounts = [];
|
||||
for (let i = 0; i < totalSegments; i++) {
|
||||
rawCounts.push(data[i] || 0);
|
||||
}
|
||||
|
||||
// Apply weighted moving average to smooth individual spikes
|
||||
const counts = smoothData(rawCounts);
|
||||
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
|
||||
// Remove existing heatmap if present
|
||||
if (heatmapContainer) {
|
||||
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
|
||||
heatmapContainer.remove();
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
|
||||
const progressBar = document.querySelector('.hstream-player__progress');
|
||||
if (!progressBar) return;
|
||||
|
||||
heatmapContainer = document.createElement('div');
|
||||
heatmapContainer.className = 'hstream-player__progress-heatmap';
|
||||
heatmapContainer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
heatmapCanvas = document.createElement('canvas');
|
||||
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
|
||||
heatmapContainer.appendChild(heatmapCanvas);
|
||||
|
||||
// Insert as first child of the progress bar so it sits behind the scrubber
|
||||
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
|
||||
|
||||
// Defer drawing to get container dimensions
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
|
||||
});
|
||||
|
||||
// Redraw on resize
|
||||
heatmapResizeObserver = new ResizeObserver(() => {
|
||||
drawCurve(heatmapCanvas, counts, maxCount);
|
||||
});
|
||||
heatmapResizeObserver.observe(heatmapContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply two passes of a 5-point weighted (Gaussian) moving average.
|
||||
* Near edges falls back to a 3-point average.
|
||||
* Preserves the first and last points.
|
||||
*/
|
||||
function smoothData(data) {
|
||||
if (data.length <= 2) return [...data];
|
||||
|
||||
let result = data;
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const smoothed = [result[0]];
|
||||
|
||||
for (let i = 1; i < result.length - 1; i++) {
|
||||
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
|
||||
smoothed.push(
|
||||
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
|
||||
result[i + 1] * 2 + result[i + 2] * 1) / 10
|
||||
);
|
||||
} else {
|
||||
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
smoothed.push(result[result.length - 1]);
|
||||
result = smoothed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vertical bar heatmap directly on the progress bar track.
|
||||
* Each bar represents a time segment; taller bars = more engagement.
|
||||
*/
|
||||
function drawCurve(canvas, counts, maxCount) {
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const rect = parent.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (counts.length === 0 || maxCount === 0) return;
|
||||
|
||||
const paddingX = 1;
|
||||
const paddingY = 2;
|
||||
const drawW = w - paddingX * 2;
|
||||
const drawH = h - paddingY * 2;
|
||||
const n = counts.length;
|
||||
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = paddingX + (i / (n - 1 || 1)) * drawW;
|
||||
const ratio = Math.min(counts[i] / maxCount, 1);
|
||||
const y = paddingY + (1 - ratio) * drawH;
|
||||
pts.push({ x, y });
|
||||
}
|
||||
|
||||
if (pts.length < 2) return;
|
||||
|
||||
// Build the smooth path using quadratic bezier curves through midpoints
|
||||
const path = [{ x: pts[0].x, y: pts[0].y }];
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const midX = (pts[i].x + pts[i + 1].x) / 2;
|
||||
const midY = (pts[i].y + pts[i + 1].y) / 2;
|
||||
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
|
||||
}
|
||||
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
|
||||
|
||||
// --- Draw a subtle glow behind the line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 3.0;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// --- Draw the main waveform line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the heatmap from the DOM.
|
||||
*/
|
||||
export function removeHeatmap() {
|
||||
if (heatmapResizeObserver) {
|
||||
heatmapResizeObserver.disconnect();
|
||||
heatmapResizeObserver = null;
|
||||
}
|
||||
if (heatmapContainer) {
|
||||
heatmapContainer.remove();
|
||||
heatmapContainer = null;
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Mobile-specific player features:
|
||||
* - Double-tap left/right to skip ±10s
|
||||
* - Object-fit toggle button for widescreen fill
|
||||
*/
|
||||
|
||||
export function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function initMobileWidescreen(playerWrapper, video) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = playerWrapper.querySelector('.hstream-player__controls');
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Toggle screen fill');
|
||||
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
|
||||
btn.title = 'Fill Screen';
|
||||
|
||||
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
|
||||
} else {
|
||||
controls.appendChild(btn);
|
||||
}
|
||||
|
||||
let fillEnabled = true;
|
||||
video.style.objectFit = 'cover';
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (fillEnabled) {
|
||||
video.style.objectFit = 'contain';
|
||||
fillEnabled = false;
|
||||
btn.classList.remove('hstream-player__button--active');
|
||||
} else {
|
||||
video.style.objectFit = 'cover';
|
||||
fillEnabled = true;
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
});
|
||||
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
|
||||
export function initMobileDoubleTap(playerWrapper, video, player) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
|
||||
if (!skipOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
class MultiClickCounter {
|
||||
constructor() {
|
||||
this.timers = [];
|
||||
this.count = 0;
|
||||
this.reseted = 0;
|
||||
this.lastSide = null;
|
||||
}
|
||||
|
||||
clicked() {
|
||||
this.count += 1;
|
||||
const xcount = this.count;
|
||||
this.timers.push(setTimeout(() => this.reset(xcount), 500));
|
||||
return this.count;
|
||||
}
|
||||
|
||||
resetCount(n) {
|
||||
this.reseted = this.count;
|
||||
this.count = n;
|
||||
this.timers.forEach(t => clearTimeout(t));
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
reset(xcount) {
|
||||
if (this.count > xcount) return;
|
||||
this.count = 0;
|
||||
this.lastSide = null;
|
||||
this.reseted = 0;
|
||||
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
|
||||
this.timers = [];
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new MultiClickCounter();
|
||||
|
||||
const handleTap = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const count = counter.clicked();
|
||||
if (count < 2) return;
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const perc = (x / rect.width) * 100;
|
||||
|
||||
let shouldReset = true;
|
||||
const lastSide = counter.lastSide;
|
||||
|
||||
if (lastSide === null) {
|
||||
shouldReset = false;
|
||||
}
|
||||
|
||||
if (perc < 40) {
|
||||
if (player.currentTime === 0) return;
|
||||
counter.lastSide = 'L';
|
||||
if (shouldReset && lastSide !== 'L') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else if (perc > 60) {
|
||||
if (player.currentTime >= player.duration) return;
|
||||
counter.lastSide = 'R';
|
||||
if (shouldReset && lastSide !== 'R') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else {
|
||||
player.togglePlay();
|
||||
counter.lastSide = 'C';
|
||||
}
|
||||
};
|
||||
|
||||
playerWrapper.addEventListener('click', handleTap);
|
||||
|
||||
video.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Builds the server/CDN selector submenu panel for the settings menu.
|
||||
* @param {string[]} streamServers - Regular CDN server URLs
|
||||
* @param {string[]} fallbackServers - Fallback server URLs
|
||||
* @param {number} selectedIndex - Index in the combined server list
|
||||
* @param {function} onSelect - Callback receiving the combined index
|
||||
*/
|
||||
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hstream-player__menu-panel';
|
||||
panel.setAttribute('data-panel', 'server');
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'hstream-player__menu-back';
|
||||
backBtn.type = 'button';
|
||||
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
|
||||
backBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const menuContainer = panel.closest('.hstream-player__menu-container');
|
||||
if (menuContainer) {
|
||||
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
|
||||
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
|
||||
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
|
||||
}
|
||||
});
|
||||
panel.appendChild(backBtn);
|
||||
|
||||
const addServerItems = (servers, labelPrefix, startIndex) => {
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const index = startIndex + i;
|
||||
const item = document.createElement('button');
|
||||
item.className = 'hstream-player__menu-item';
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitemradio');
|
||||
|
||||
if (index === selectedIndex) {
|
||||
item.classList.add('hstream-player__menu-item--checked');
|
||||
item.setAttribute('aria-checked', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
}
|
||||
|
||||
const num = i + 1;
|
||||
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(index);
|
||||
});
|
||||
panel.appendChild(item);
|
||||
}
|
||||
};
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(divider);
|
||||
|
||||
addServerItems(streamServers, 'Server', 0);
|
||||
|
||||
if (fallbackServers && fallbackServers.length > 0) {
|
||||
const fbDivider = document.createElement('div');
|
||||
fbDivider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(fbDivider);
|
||||
|
||||
addServerItems(fallbackServers, 'Fallback', streamServers.length);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* VTT-based sprite thumbnail preview.
|
||||
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
|
||||
* a floating preview image above the progress bar on hover.
|
||||
*/
|
||||
|
||||
export class ThumbnailPreview {
|
||||
constructor(progressWrapper, video) {
|
||||
this.progressWrapper = progressWrapper;
|
||||
this.video = video;
|
||||
this.cues = [];
|
||||
this.spriteImg = null;
|
||||
this.thumbnailWidth = 160;
|
||||
this.thumbnailHeight = 90;
|
||||
this.visible = false;
|
||||
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'hstream-player__thumbnail-preview';
|
||||
this.el.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.imgEl = document.createElement('div');
|
||||
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
|
||||
this.el.appendChild(this.imgEl);
|
||||
|
||||
this.timeEl = document.createElement('div');
|
||||
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
|
||||
this.el.appendChild(this.timeEl);
|
||||
|
||||
this.el.style.display = 'none';
|
||||
this.progressWrapper.appendChild(this.el);
|
||||
|
||||
this._onMove = this._onMove.bind(this);
|
||||
this._onLeave = this._onLeave.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the thumbnail VTT file.
|
||||
* @param {string} vttUrl
|
||||
*/
|
||||
async load(vttUrl) {
|
||||
try {
|
||||
const response = await fetch(vttUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VTT: ' + response.status);
|
||||
}
|
||||
const text = await response.text();
|
||||
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
|
||||
this.cues = this._parseVTT(text, baseDir);
|
||||
if (this.cues.length > 0) {
|
||||
this.spriteImg = new Image();
|
||||
this.spriteImg.crossOrigin = 'anonymous';
|
||||
this.spriteImg.src = this.cues[0].spriteUrl;
|
||||
await new Promise((resolve, reject) => {
|
||||
this.spriteImg.onload = resolve;
|
||||
this.spriteImg.onerror = reject;
|
||||
});
|
||||
}
|
||||
this._attach();
|
||||
} catch (err) {
|
||||
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WEBVTT text extracting cues with sprite coordinates.
|
||||
*/
|
||||
_parseVTT(text, baseDir) {
|
||||
const cues = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
|
||||
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
|
||||
|
||||
const resolveUrl = (maybeRelative) => {
|
||||
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
|
||||
return maybeRelative;
|
||||
}
|
||||
try {
|
||||
return new URL(maybeRelative, baseDir).href;
|
||||
} catch (e) {
|
||||
return baseDir + maybeRelative;
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
const match = line.match(cueRegex);
|
||||
if (match) {
|
||||
const startTime = this._timeToSeconds(match[1]);
|
||||
const endTime = this._timeToSeconds(match[2]);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const payload = lines[i].trim();
|
||||
if (payload === '' || payload.match(cueRegex)) {
|
||||
break;
|
||||
}
|
||||
const xywh = payload.match(xywhRegex);
|
||||
if (xywh) {
|
||||
const rawUrl = payload.substring(0, xywh.index);
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
spriteUrl: resolveUrl(rawUrl),
|
||||
x: parseInt(xywh[1], 10),
|
||||
y: parseInt(xywh[2], 10),
|
||||
w: parseInt(xywh[3], 10),
|
||||
h: parseInt(xywh[4], 10),
|
||||
});
|
||||
break;
|
||||
}
|
||||
const noteMatch = payload.match(/^NOTE/);
|
||||
if (!noteMatch) {
|
||||
const urlMatch = payload.match(/^(\S+)/);
|
||||
if (urlMatch) {
|
||||
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
_timeToSeconds(timestamp) {
|
||||
const [h, m, s] = timestamp.split(':');
|
||||
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
|
||||
}
|
||||
|
||||
_attach() {
|
||||
this.progressWrapper.addEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
|
||||
this.progressWrapper.addEventListener('touchend', this._onLeave);
|
||||
}
|
||||
|
||||
_onMove(e) {
|
||||
const rect = this.progressWrapper.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const time = ratio * this.video.duration;
|
||||
|
||||
const cue = this._findCue(time);
|
||||
if (!cue) {
|
||||
this._hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this._show(cue, time, rect, x);
|
||||
}
|
||||
|
||||
_findCue(time) {
|
||||
for (let i = 0; i < this.cues.length; i++) {
|
||||
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
|
||||
return this.cues[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_show(cue, time, progressRect, mouseX) {
|
||||
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
|
||||
this.imgEl.style.width = cue.w + 'px';
|
||||
this.imgEl.style.height = cue.h + 'px';
|
||||
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
|
||||
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
|
||||
const containerWidth = this.progressWrapper.offsetWidth;
|
||||
const halfW = cue.w / 2;
|
||||
let left = mouseX;
|
||||
if (left < halfW + 4) left = halfW + 4;
|
||||
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
|
||||
|
||||
this.el.style.left = left + 'px';
|
||||
this.el.style.display = '';
|
||||
|
||||
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
|
||||
if (timeTooltip) {
|
||||
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
|
||||
}
|
||||
|
||||
if (!this.visible) {
|
||||
this.visible = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.el.classList.add('hstream-player__thumbnail-preview--visible');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_hide() {
|
||||
this.visible = false;
|
||||
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
|
||||
setTimeout(() => {
|
||||
if (!this.visible) {
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
_onLeave() {
|
||||
this._hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.progressWrapper.removeEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.removeEventListener('touchmove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('touchend', this._onLeave);
|
||||
if (this.el.parentNode) {
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-45
@@ -1,56 +1,52 @@
|
||||
const sleep = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
var old_timestamp = document.getElementById('ts_reference').value;
|
||||
function initGalleryPreviews() {
|
||||
const previews = document.querySelectorAll('.preview-gallery');
|
||||
|
||||
function initPreviews() {
|
||||
var thumbs = document.querySelectorAll('div[data-thumbs]');
|
||||
thumbs.forEach(function (thumb) {
|
||||
var thumbsJSON = JSON.parse(thumb.dataset.thumbs);
|
||||
var originalImage = thumb.children[0].children[1].src;
|
||||
var interval;
|
||||
var i = 1;
|
||||
previews.forEach((img) => {
|
||||
// Prevent double initialization
|
||||
if (img.dataset.previewInitialized) return;
|
||||
|
||||
function clear() {
|
||||
thumb.children[0].children[1].src = originalImage;
|
||||
i = 1;
|
||||
clearTimeout(interval);
|
||||
img.dataset.previewInitialized = 'true';
|
||||
|
||||
let images = [];
|
||||
|
||||
try {
|
||||
images = JSON.parse(img.dataset.gallery);
|
||||
} catch (e) {
|
||||
console.error('Invalid gallery JSON', e);
|
||||
return;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (i == 0) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (images.length <= 1) return;
|
||||
|
||||
thumb.children[0].children[1].src = thumbsJSON[i];
|
||||
i = (i + 1) % thumbsJSON.length;
|
||||
}
|
||||
const original = img.src;
|
||||
|
||||
function interval() {
|
||||
// Start Preview
|
||||
interval = setInterval(toggle, 700);
|
||||
}
|
||||
let index = 0;
|
||||
let interval = null;
|
||||
|
||||
thumb.addEventListener('mouseenter', interval);
|
||||
thumb.addEventListener('mouseleave', clear);
|
||||
const startPreview = () => {
|
||||
console.log("startPreview");
|
||||
interval = setInterval(() => {
|
||||
index = (index + 1) % images.length;
|
||||
img.src = images[index];
|
||||
}, 700);
|
||||
};
|
||||
|
||||
const stopPreview = () => {
|
||||
console.log("stopPreview");
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
|
||||
index = 0;
|
||||
img.src = original;
|
||||
};
|
||||
|
||||
img.addEventListener('mouseenter', startPreview);
|
||||
img.addEventListener('mouseleave', stopPreview);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
var new_timestamp = document.getElementById('ts_reference').value;
|
||||
if (new_timestamp != old_timestamp) {
|
||||
console.log('== Changed ==');
|
||||
initPreviews();
|
||||
break;
|
||||
}
|
||||
console.log('== Didnt Change ==');
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
// Initial page load
|
||||
document.addEventListener('DOMContentLoaded', initGalleryPreviews);
|
||||
|
||||
window.addEventListener('contentChanged', event => {
|
||||
console.log('== Received contentChanged Event ==');
|
||||
init();
|
||||
});
|
||||
|
||||
initPreviews();
|
||||
// Livewire v3 navigation/update
|
||||
document.addEventListener('contentChanged', initGalleryPreviews);
|
||||
+276
-36
@@ -1,73 +1,313 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
// Theming
|
||||
if (localStorage.theme !== 'light') {
|
||||
Chart.defaults.color = "#ADBABD";
|
||||
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
|
||||
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
|
||||
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
|
||||
/**
|
||||
* Theme-aware chart defaults
|
||||
*/
|
||||
function getChartColors() {
|
||||
const isDark = localStorage.theme !== 'light' &&
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
textColor: '#ADBABD',
|
||||
gridColor: 'rgba(255, 255, 255, 0.06)',
|
||||
fillStart: 'rgba(190, 18, 60, 0.2)',
|
||||
fillEnd: 'rgba(190, 18, 60, 0.0)',
|
||||
borderColor: 'rgba(190, 18, 60, 0.9)',
|
||||
pointColor: 'rgba(190, 18, 60, 1)',
|
||||
pointHoverColor: '#ffffff',
|
||||
skeletonBase: '#262626',
|
||||
skeletonShimmer: '#333333',
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (response.status != 200) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Show the skeleton loader
|
||||
*/
|
||||
function showSkeleton() {
|
||||
const skeleton = document.getElementById('chart-skeleton');
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
const error = document.getElementById('chart-error');
|
||||
|
||||
if (skeleton) skeleton.style.display = '';
|
||||
if (canvas) canvas.style.opacity = '0';
|
||||
if (error) error.classList.add('hidden');
|
||||
}
|
||||
|
||||
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: [{
|
||||
label: 'Views',
|
||||
fill: false,
|
||||
backgroundColor: 'rgba(190, 18, 60, 0.3)',
|
||||
borderColor: 'rgba(190, 18, 60, 1.0)',
|
||||
fill: true,
|
||||
backgroundColor: gradient,
|
||||
borderColor: colors.borderColor,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: colors.pointColor,
|
||||
pointBorderColor: colors.pointColor,
|
||||
pointHoverBackgroundColor: colors.pointHoverColor,
|
||||
pointHoverBorderColor: colors.borderColor,
|
||||
pointHoverBorderWidth: 2,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 2.5,
|
||||
pointHitRadius: 20,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
data: response.data.map((entry) => { return entry.count }),
|
||||
tension: 0.4,
|
||||
data: data.map((entry) => entry.count),
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: data,
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 1200,
|
||||
easing: 'easeOutQuart',
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Views the last 28 days',
|
||||
font: {
|
||||
size: 18
|
||||
display: false,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 17, 17, 0.95)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#D1D5DB',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 10,
|
||||
displayColors: false,
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
},
|
||||
titleFont: {
|
||||
size: 12,
|
||||
weight: '600',
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return 'Views: ' + new Intl.NumberFormat().format(context.parsed.y);
|
||||
},
|
||||
title: function(context) {
|
||||
return context[0].label;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: colors.gridColor,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.textColor,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
maxTicksLimit: 14,
|
||||
maxRotation: 0,
|
||||
},
|
||||
title: {
|
||||
display: true
|
||||
display: false,
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Views'
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: colors.gridColor,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.textColor,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(1) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
|
||||
return value;
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 40000
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const monthlyViewChart = new Chart(
|
||||
document.getElementById('monthlyChart'),
|
||||
config
|
||||
);
|
||||
}).catch(function (error) {
|
||||
console.log(error);
|
||||
hideError();
|
||||
monthlyViewChart = new Chart(canvas, config);
|
||||
hideSkeleton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch chart data from the API
|
||||
*/
|
||||
async function fetchChartData() {
|
||||
showSkeleton();
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/v1/monthly-views');
|
||||
|
||||
if (response.status !== 200 || !response.data || response.data.length === 0) {
|
||||
throw new Error('Invalid or empty response');
|
||||
}
|
||||
|
||||
renderChart(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chart data:', error);
|
||||
showError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry loading the chart (called from error state button)
|
||||
*/
|
||||
window.retryChart = function() {
|
||||
if (monthlyViewChart) {
|
||||
monthlyViewChart.destroy();
|
||||
monthlyViewChart = null;
|
||||
}
|
||||
fetchChartData();
|
||||
};
|
||||
|
||||
// Listen for theme changes to re-render chart
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
if (monthlyViewChart) {
|
||||
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||
date: monthlyViewChart.data.labels[index],
|
||||
count: value,
|
||||
}));
|
||||
renderChart(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Observe theme class changes on html element
|
||||
const htmlElement = document.documentElement;
|
||||
if (htmlElement) {
|
||||
themeObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
// Start the fetch when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Small delay to ensure the page has rendered and skeleton is visible
|
||||
setTimeout(() => {
|
||||
fetchChartData();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Handle window resize for theme changes (system preference)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (!('theme' in localStorage) && monthlyViewChart) {
|
||||
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||
date: monthlyViewChart.data.labels[index],
|
||||
count: value,
|
||||
}));
|
||||
renderChart(data);
|
||||
}
|
||||
});
|
||||
@@ -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 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%]">
|
||||
<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">
|
||||
<div
|
||||
data-te-modal-init
|
||||
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')"/>
|
||||
|
||||
<!--Modal body-->
|
||||
<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
|
||||
<div class="grid grid-cols-3">
|
||||
<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 class="flex flex-col gap-2 p-2">
|
||||
<div>
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title">Title:</label>
|
||||
<x-text-input id="title" value="{{ $episode->title }}" class="block w-full" type="text" name="title" required autofocus/>
|
||||
</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">
|
||||
<!-- Studio -->
|
||||
<div class="p-2 pt-0">
|
||||
@@ -47,13 +67,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<!-- Stream URL -->
|
||||
<div class="p-2 pt-0">
|
||||
<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 />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<!-- Episodes -->
|
||||
<div class="grid grid-cols-2">
|
||||
<!-- Cover -->
|
||||
@@ -95,8 +119,10 @@
|
||||
<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 ?? '' }}" />
|
||||
</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">
|
||||
<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" />
|
||||
@@ -111,10 +137,17 @@
|
||||
DMCA Takedown
|
||||
</label>
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
@auth
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<div class="relative p-5 bg-white dark:bg-neutral-700/40 rounded-lg overflow-hidden z-10">
|
||||
<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>
|
||||
<div class="float-right">
|
||||
<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>
|
||||
<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
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-guest-layout>
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-guest-layout>
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="w-full sm:max-w-md mt-6">
|
||||
<ul class="flex list-none flex-row flex-wrap border-b-0 pl-0 relative " role="tablist" data-te-nav-ref>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-800 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-login" data-te-nav-active role="tab" aria-controls="tabs-login" aria-selected="true">
|
||||
{{ __('Login') }}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-800 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-register" role="tab" aria-controls="tabs-register" aria-selected="false">
|
||||
{{ __('Register') }}
|
||||
</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<!-- Login -->
|
||||
<div class="w-full sm:max-w-md hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-login" role="tabpanel" aria-labelledby="tabs-login" data-te-tab-active>
|
||||
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="w-full text-center text-white mb-3">
|
||||
<a href="{{ route('discord.login') }}">
|
||||
<div
|
||||
@@ -28,6 +28,14 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="grid grid-cols-3">
|
||||
<hr class="self-center border-neutral-600">
|
||||
@@ -102,7 +110,7 @@
|
||||
|
||||
<!-- Register -->
|
||||
<div class="w-full sm:max-w-md hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-register" role="tabpanel" aria-labelledby="tabs-register">
|
||||
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@csrf
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-guest-layout>
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<form method="POST" action="{{ route('password.store') }}">
|
||||
@csrf
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-guest-layout>
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
@props([
|
||||
'episode',
|
||||
'view',
|
||||
'displayjapanese' => false
|
||||
])
|
||||
|
||||
@php
|
||||
$title = $displayjapanese
|
||||
? "{$episode->title_jpn} ({$episode->title}) - {$episode->episode}"
|
||||
: "{$episode->title} - {$episode->episode}";
|
||||
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
$isWatched = $isLoggedIn
|
||||
? $episode->userWatched(auth()->id())
|
||||
: false;
|
||||
|
||||
$problematic = cache()->rememberForever(
|
||||
"episodeProblematic{$episode->id}",
|
||||
fn () => $episode->getProblematicTags()
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="group w-full p-1">
|
||||
<a
|
||||
href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
|
||||
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">
|
||||
|
||||
{{-- Thumbnail / Cover --}}
|
||||
@if ($view === 'poster')
|
||||
<img
|
||||
src="{{ $episode->cover_url }}"
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
width="400"
|
||||
class="aspect-[11/16] w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
>
|
||||
@elseif ($view === 'thumbnail')
|
||||
@php
|
||||
$galleryImages = $episode->gallery
|
||||
->pluck('thumbnail_url')
|
||||
->filter()
|
||||
->values();
|
||||
@endphp
|
||||
<img
|
||||
src="{{ $galleryImages->first() }}"
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
width="1000"
|
||||
data-gallery='@json($galleryImages)'
|
||||
class="preview-gallery aspect-video w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
>
|
||||
@endif
|
||||
|
||||
{{-- Dark Overlay --}}
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent"></div>
|
||||
|
||||
{{-- Top Meta --}}
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3">
|
||||
|
||||
{{-- Problematic Tags --}}
|
||||
@if (!empty($problematic))
|
||||
<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>
|
||||
{{ $problematic }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Resolution --}}
|
||||
<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() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bottom Content --}}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-4">
|
||||
|
||||
{{-- 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 --}}
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm font-bold text-neutral-200">
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-regular fa-eye text-neutral-200 font-bold"></i>
|
||||
{{ $episode->viewCountFormatted() }}
|
||||
</span>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-regular fa-heart text-neutral-200 font-bold"></i>
|
||||
{{ $episode->likeCount() }}
|
||||
</span>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-regular fa-comment text-neutral-200 font-bold"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Watched Status (logged in users only) --}}
|
||||
@auth
|
||||
@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">
|
||||
@if ($view === 'thumbnail')
|
||||
<i class="fa-solid fa-eye mr-1"></i> Watched
|
||||
@else
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="shrink-0 rounded-full bg-rose-800/40 px-2.5 py-1 text-xs font-semibold text-rose-300 ring-1 ring-rose-500/30">
|
||||
<i class="fa-solid fa-eye-slash"></i>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,58 +0,0 @@
|
||||
@props(['episode'])
|
||||
|
||||
<div class="relative p-1 mb-8 w-full transition duration-300 ease-in-out md:p-2 md:hover:-translate-y-1 md:hover:scale-110">
|
||||
<a class="hover:text-blue-600" href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"></img>
|
||||
|
||||
@guest
|
||||
<p
|
||||
class="absolute right-1 md:right-2 top-1 md:top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<p
|
||||
class="absolute left-1 md:left-4 bottom-1 md:bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i class="fa-regular fa-heart"></i>
|
||||
{{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@endguest
|
||||
|
||||
@php $problematic = cache()->rememberForever('episodeProblematic'.$episode->id, fn () => $episode->getProblematicTags()); @endphp
|
||||
@if (!empty($problematic))
|
||||
<p
|
||||
class="absolute left-4 top-2 bg-red-700/70 !text-white rounded-br-lg rounded-tl-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ $problematic }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
@if ($episode->userWatched(auth()->user()->id))
|
||||
<p
|
||||
class="absolute right-1 md:right-2 top-1 md:top-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<p
|
||||
class="absolute left-1 md:left-2 bottom-1 md:bottom-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@else
|
||||
<p
|
||||
class="absolute right-1 md:right-2 top-1 md:top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<p
|
||||
class="absolute left-1 md:left-2 bottom-1 md:bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
<div class="absolute w-[95%] grid grid-cols-1 text-center">
|
||||
<p class="text-sm text-center text-black dark:text-white">{{ $episode->title }} -
|
||||
{{ $episode->episode }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,13 +1,19 @@
|
||||
@props(['title'])
|
||||
<div class="flex flex-shrink-0 items-center justify-between rounded-t-md p-4 bg-rose-600">
|
||||
<!--Modal title-->
|
||||
<h5 class="text-xl font-medium leading-normal text-white">
|
||||
{{ $title }}
|
||||
</h5>
|
||||
<!--Close button-->
|
||||
<button type="button" class="box-content text-white rounded-none border-none hover:no-underline hover:opacity-75 focus:opacity-100 focus:shadow-none focus:outline-none" data-te-modal-dismiss aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
<div>
|
||||
<h2
|
||||
id="modalGenresLabel"
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white"
|
||||
>
|
||||
{{ $title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
class="rounded-lg p-2 text-neutral-500 transition hover:bg-neutral-100 hover:text-black dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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>
|
||||
@@ -1,61 +1,83 @@
|
||||
<p class="leading-normal font-bold text-lg text-neutral-800 dark:text-white">
|
||||
<p class="text-lg font-bold leading-normal text-neutral-800 dark:text-white">
|
||||
{{ __('home.categories') }}
|
||||
</p>
|
||||
|
||||
@php
|
||||
$categories = [
|
||||
'Uncensored' => 'uncensored',
|
||||
'Milf' => 'milf',
|
||||
'Maid' => 'maid',
|
||||
'School Girl' => 'school-girl',
|
||||
'Succubus' => 'succubus',
|
||||
'Tentacle' => 'tentacle',
|
||||
'Big Boobs' => 'big-boobs',
|
||||
'BDSM' => 'bdsm',
|
||||
'Elf' => 'elf',
|
||||
'4k 48fps' => '4k-48fps',
|
||||
];
|
||||
$categories = collect([
|
||||
['name' => 'Uncensored', 'slug' => 'uncensored'],
|
||||
['name' => 'Milf', 'slug' => 'milf'],
|
||||
['name' => 'Maid', 'slug' => 'maid'],
|
||||
['name' => 'School Girl', 'slug' => 'school-girl'],
|
||||
['name' => 'Succubus', 'slug' => 'succubus'],
|
||||
['name' => 'Tentacle', 'slug' => 'tentacle'],
|
||||
['name' => 'Big Boobs', 'slug' => 'big-boobs'],
|
||||
['name' => 'BDSM', 'slug' => 'bdsm'],
|
||||
['name' => 'Elf', 'slug' => 'elf'],
|
||||
['name' => '4K 48FPS', 'slug' => '4k-48fps'],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5 gap-2">
|
||||
@foreach ($categories as $name => $slug)
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
@foreach ($categories as $category)
|
||||
@php
|
||||
$cacheKey = 'category_' . $slug;
|
||||
|
||||
$collection = \cache()->remember(
|
||||
$cacheKey,
|
||||
900,
|
||||
fn() => \App\Models\Episode::withAllTags([$slug])
|
||||
$episodes = cache()->remember(
|
||||
"category_{$category['slug']}",
|
||||
now()->addMinutes(15),
|
||||
fn () => \App\Models\Episode::query()
|
||||
->withAllTags([$category['slug']])
|
||||
->inRandomOrder()
|
||||
->limit(3)
|
||||
->get(),
|
||||
->get()
|
||||
);
|
||||
|
||||
$count = $collection->count();
|
||||
|
||||
[$left, $center, $right] = [
|
||||
$episodes->get(0),
|
||||
$episodes->get(1),
|
||||
$episodes->get(2),
|
||||
];
|
||||
@endphp
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $slug]) }}"
|
||||
class="relative mx-auto w-96 sm:w-full h-56 bg-white dark:bg-neutral-800 text-black dark:text-white rounded-lg overflow-hidden shadow-lg mt-4 transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
|
||||
<h2 class="text-lg font-semibold text-center pt-2">{{ $name }}</h2>
|
||||
<div class="relative w-full h-full flex justify-center">
|
||||
<!-- Left Image -->
|
||||
@if ($count > 0)
|
||||
<img src="{{ $collection->first()->cover_url }}"
|
||||
class="absolute w-32 h-44 rounded-lg object-cover shadow-md left-4 top-4 rotate-[-15deg] z-0">
|
||||
@endif
|
||||
|
||||
<!-- Center Image -->
|
||||
@if ($count > 1)
|
||||
<img src="{{ $collection->skip(1)->first()->cover_url }}"
|
||||
class="absolute w-32 h-44 rounded-lg object-cover shadow-lg top-4 z-10">
|
||||
@endif
|
||||
<a
|
||||
href="{{ route('hentai.search', [
|
||||
'order' => 'recently-uploaded',
|
||||
'tags[0]' => $category['slug'],
|
||||
]) }}"
|
||||
class="group relative overflow-hidden rounded-2xl border dark:border-neutral-800 border-neutral-300 dark:bg-neutral-900 dark:hover:border-neutral-700 hover:border-neutral-400 hover:shadow-2xl hover:shadow-black/30 shadow-md transition-all duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h2 class="text-center text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
{{ $category['name'] }}
|
||||
</h2>
|
||||
|
||||
<!-- Right Image -->
|
||||
@if ($count > 2)
|
||||
<img src="{{ $collection->skip(2)->first()->cover_url }}"
|
||||
class="absolute w-32 h-44 rounded-lg object-cover shadow-lg right-4 top-14 z-20 rotate-[15deg]">
|
||||
@endif
|
||||
<div class="relative mt-4 flex h-52 items-center justify-center">
|
||||
@if ($left)
|
||||
<img
|
||||
src="{{ $left->cover_url }}"
|
||||
alt="{{ $category['name'] }}"
|
||||
loading="lazy"
|
||||
class="absolute left-2 top-4 h-44 w-32 rotate-[-12deg] rounded-xl object-cover shadow-lg transition-transform duration-300 group-hover:rotate-[-16deg]"
|
||||
>
|
||||
@endif
|
||||
|
||||
@if ($center)
|
||||
<img
|
||||
src="{{ $center->cover_url }}"
|
||||
alt="{{ $category['name'] }}"
|
||||
loading="lazy"
|
||||
class="absolute top-2 z-10 h-44 w-32 rounded-xl object-cover shadow-2xl transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
@endif
|
||||
|
||||
@if ($right)
|
||||
<img
|
||||
src="{{ $right->cover_url }}"
|
||||
alt="{{ $category['name'] }}"
|
||||
loading="lazy"
|
||||
class="absolute right-2 top-8 z-20 h-44 w-32 rotate-[12deg] rounded-xl object-cover shadow-lg transition-transform duration-300 group-hover:rotate-[16deg]"
|
||||
>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +1,80 @@
|
||||
<p class="text-lg font-bold leading-normal text-neutral-800 dark:text-white">
|
||||
<p class="mb-6 text-2xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
{{ __('home.latest-comments') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-2 grid-cols-1 xl:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
@foreach ($latestComments as $comment)
|
||||
@if ($comment->commentable_type == \App\Models\Episode::class)
|
||||
@php $episode = cache()->rememberForever('commentEpisode'.$comment->commentable_id, fn () => App\Models\Episode::with('gallery')->where('id', $comment->commentable_id)->first()); @endphp
|
||||
<div id="comments" class="flex p-4 bg-white rounded-lg dark:bg-neutral-950">
|
||||
<div
|
||||
class="w-[15vw] mr-5 p-1 md:p-2 mb-4 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
|
||||
<a class="hidden 2xl:block"
|
||||
href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"></img>
|
||||
<p
|
||||
class="absolute right-2 top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<div class="absolute w-[95%] grid grid-cols-1 text-center">
|
||||
<p class="text-sm text-center text-black dark:text-white truncate">{{ $episode->title }} -
|
||||
{{ $episode->episode }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="block 2xl:hidden"
|
||||
href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg"
|
||||
src="{{ $episode->cover_url }}"></img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-[60vw] pt-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg pl-4">
|
||||
@php
|
||||
$isEpisode = $comment->commentable_type == \App\Models\Episode::class;
|
||||
|
||||
if ($isEpisode) {
|
||||
$item = cache()->rememberForever(
|
||||
'commentEpisode' . $comment->commentable_id,
|
||||
fn () => \App\Models\Episode::with('gallery')
|
||||
->find($comment->commentable_id)
|
||||
);
|
||||
|
||||
$url = route('hentai.index', ['title' => $item->slug]);
|
||||
$title = $item->title . ' - ' . $item->episode;
|
||||
$thumbnail = $item->gallery->first()?->thumbnail_url ?? $item->cover_url;
|
||||
$cover = $item->cover_url;
|
||||
$resolution = $item->getResolution();
|
||||
} else {
|
||||
$item = cache()->rememberForever(
|
||||
'commentHentai' . $comment->commentable_id,
|
||||
fn () => \App\Models\Hentai::with('gallery', 'episodes')
|
||||
->find($comment->commentable_id)
|
||||
);
|
||||
|
||||
$episode = $item->episodes->first();
|
||||
|
||||
$url = route('hentai.index', ['title' => $item->slug]);
|
||||
$title = $episode?->title;
|
||||
$thumbnail = $item->gallery->first()?->thumbnail_url ?? $episode?->cover_url;
|
||||
$cover = $episode?->cover_url;
|
||||
$resolution = $episode?->getResolution();
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="group overflow-hidden rounded-2xl border dark:border-neutral-800 border-neutral-300 dark:bg-neutral-900 dark:hover:border-neutral-700 hover:border-neutral-400 hover:shadow-2xl hover:shadow-black/30 shadow-md transition-all duration-300 hover:-translate-y-1">
|
||||
|
||||
<div class="flex flex-col md:flex-row">
|
||||
|
||||
{{-- Thumbnail --}}
|
||||
<a href="{{ $url }}"
|
||||
class="relative w-full md:w-72 shrink-0 overflow-hidden">
|
||||
|
||||
{{-- Desktop Thumbnail --}}
|
||||
<img
|
||||
src="{{ $thumbnail }}"
|
||||
alt="{{ $title }}"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105 aspect-video"
|
||||
>
|
||||
|
||||
{{-- Resolution Badge --}}
|
||||
<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">
|
||||
{{ $resolution }}
|
||||
</div>
|
||||
|
||||
{{-- Gradient Overlay --}}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-4">
|
||||
<p class="line-clamp-1 text-sm font-medium text-white">
|
||||
{{ $title }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Comment Content --}}
|
||||
<div class="flex-1 p-4 md:p-6 bg-neutral-50 dark:bg-neutral-900">
|
||||
@include('partials.comment', ['comment' => $comment])
|
||||
</div>
|
||||
</div>
|
||||
@elseif($comment->commentable_type == \App\Models\Hentai::class)
|
||||
@php $hentai = cache()->rememberForever('commentHentai'.$comment->commentable_id, fn () => App\Models\Hentai::with('gallery', 'episodes')->where('id', $comment->commentable_id)->first()); @endphp
|
||||
<div id="comments" class="flex p-4 bg-white rounded-lg dark:bg-neutral-950">
|
||||
<div
|
||||
class="w-[15vw] mr-5 p-1 md:p-2 mb-8 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
|
||||
<a class="hidden 2xl:block" href="{{ route('hentai.index', ['title' => $hentai->slug]) }}">
|
||||
<img alt="{{ $hentai->episodes->first()->title }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
|
||||
src="{{ $hentai->gallery->first()->thumbnail_url }}"></img>
|
||||
<p
|
||||
class="absolute right-2 top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $hentai->episodes->first()->getResolution() }}</p>
|
||||
<div class="absolute w-[95%] grid grid-cols-1 text-center">
|
||||
<p class="text-sm text-center text-black dark:text-white truncate">
|
||||
{{ $hentai->episodes->first()->title }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="block 2xl:hidden"
|
||||
href="{{ route('hentai.index', ['title' => $hentai->slug]) }}">
|
||||
<img alt="{{ $hentai->episodes->first()->title }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg"
|
||||
src="{{ $hentai->episodes->first()->cover_url }}"></img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-[60vw] pt-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg pl-4">
|
||||
@include('partials.comment', ['comment' => $comment])
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
@php
|
||||
$random = \cache()->remember('random_home', 300, function () {
|
||||
return \App\Models\Episode::inRandomOrder()->limit(8)->get(); ;
|
||||
return \App\Models\Episode::inRandomOrder()->limit(16)->get(); ;
|
||||
});
|
||||
@endphp
|
||||
|
||||
<div class="mb-6">
|
||||
@include('home.partials.tab.template', ['episodes' => $random, 'showThumbnails' => false])
|
||||
@include('home.partials.tab.template', ['episodes' => $random, 'isThumbnail' => false])
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
@if ($showThumbnails)
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
|
||||
@include('partials.episode-thumbnail', ['limit' => 15])
|
||||
</div>
|
||||
@props(['isThumbnail'])
|
||||
|
||||
@php
|
||||
// Render enough items for largest possible layout
|
||||
$limit = 16;
|
||||
|
||||
$view = $isThumbnail ? 'thumbnail' : 'poster';
|
||||
@endphp
|
||||
|
||||
@if ($isThumbnail)
|
||||
<div
|
||||
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
|
||||
[&>.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 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-2">
|
||||
@include('partials.episode-cover', ['limit' => 16])
|
||||
</div>
|
||||
@endif
|
||||
<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)
|
||||
@php
|
||||
$episode = isset($popularView)
|
||||
? $ep->episode
|
||||
: $ep;
|
||||
@endphp
|
||||
|
||||
<div class="episode-item p-1">
|
||||
<x-episode-cover
|
||||
:episode="$episode"
|
||||
:view="$view"
|
||||
/>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
<ul class="-mb-6 flex list-none flex-row flex-wrap border-b-0 pl-0 relative z-10" role="tablist" data-te-nav-ref>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-most-views"
|
||||
class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
class="rounded-l-xl my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-most-views" data-te-nav-active role="tab"
|
||||
aria-controls="tabs-most-views" aria-selected="true">
|
||||
{{ __('home.most-views') }}
|
||||
@@ -10,7 +10,7 @@
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-most-likes"
|
||||
class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-most-likes" role="tab" aria-controls="tabs-most-likes"
|
||||
aria-selected="false">
|
||||
{{ __('home.most-likes') }}
|
||||
@@ -18,7 +18,7 @@
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-popular-weekly"
|
||||
class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-popular-weekly" role="tab"
|
||||
aria-controls="tabs-popular-weekly" aria-selected="false">
|
||||
{{ __('home.popular-weekly') }}
|
||||
@@ -26,7 +26,7 @@
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-popular-monthly"
|
||||
class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
class="rounded-r-xl my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-popular-monthly" role="tab"
|
||||
aria-controls="tabs-popular-monthly" aria-selected="false">
|
||||
{{ __('home.popular-monthly') }}
|
||||
@@ -34,11 +34,11 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@php $showThumbnails = true; @endphp
|
||||
@php $isThumbnail = true; @endphp
|
||||
|
||||
@auth
|
||||
@if (!Auth::user()->home_middle_design)
|
||||
@php $showThumbnails = false; @endphp
|
||||
@php $isThumbnail = false; @endphp
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
id="tabs-most-views" role="tabpanel" aria-labelledby="tabs-most-views-tab" data-te-tab-active>
|
||||
@include('home.partials.tab.template', [
|
||||
'episodes' => $popularAllTime,
|
||||
'showThumbnails' => $showThumbnails,
|
||||
'isThumbnail' => $isThumbnail,
|
||||
])
|
||||
<div class="grid text-center pt-5 ">
|
||||
<a href="{{ route('hentai.search', ['order' => 'view-count']) }}"
|
||||
@@ -61,7 +61,7 @@
|
||||
id="tabs-most-likes" role="tabpanel" aria-labelledby="tabs-most-likes-tab">
|
||||
@include('home.partials.tab.template', [
|
||||
'episodes' => $mostLikes,
|
||||
'showThumbnails' => $showThumbnails,
|
||||
'isThumbnail' => $isThumbnail,
|
||||
])
|
||||
<div class="grid text-center pt-5 ">
|
||||
<a href="{{ route('hentai.search', ['order' => 'view-count']) }}"
|
||||
@@ -74,7 +74,7 @@
|
||||
id="tabs-popular-weekly" role="tabpanel" aria-labelledby="tabs-popular-weekly-tab">
|
||||
@include('home.partials.tab.template', [
|
||||
'episodes' => $popularWeekly,
|
||||
'showThumbnails' => $showThumbnails,
|
||||
'isThumbnail' => $isThumbnail,
|
||||
'popularView' => true,
|
||||
])
|
||||
<div class="grid text-center pt-5 ">
|
||||
@@ -88,7 +88,7 @@
|
||||
id="tabs-popular-monthly" role="tabpanel" aria-labelledby="tabs-popular-monthly-tab">
|
||||
@include('home.partials.tab.template', [
|
||||
'episodes' => $popularMonthly,
|
||||
'showThumbnails' => $showThumbnails,
|
||||
'isThumbnail' => $isThumbnail,
|
||||
'popularView' => true,
|
||||
])
|
||||
<div class="grid text-center pt-5 ">
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<!--Tabs navigation-->
|
||||
<ul class="-mb-6 flex list-none flex-row flex-wrap border-b-0 pl-0 relative z-10" role="tablist" data-te-nav-ref>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-recently-uploaded" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
<a href="#tabs-recently-uploaded" class="rounded-l-xl my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm dark:hover:dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-recently-uploaded" data-te-nav-active role="tab" aria-controls="tabs-recently-uploaded" aria-selected="true">
|
||||
{{ __('home.recently-uploaded') }} ({{ Carbon\Carbon::parse($recentlyUploaded[0]->created_at)->diffForHumans([ 'parts' => 2 ]) }})
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-recently-released" class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
<a href="#tabs-recently-released" class="my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-recently-released" role="tab" aria-controls="tabs-recently-released" aria-selected="false">
|
||||
{{ __('home.recently-released') }}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" class="flex-auto text-center">
|
||||
<a href="#tabs-trending" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
||||
<a href="#tabs-trending" class="rounded-r-xl my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||
data-te-toggle="pill" data-te-target="#tabs-trending" role="tab" aria-controls="tabs-trending" aria-selected="false">
|
||||
{{ __('home.trending') }}
|
||||
</a>
|
||||
@@ -21,18 +21,18 @@
|
||||
|
||||
</ul>
|
||||
|
||||
@php $showThumbnails = false; @endphp
|
||||
@php $isThumbnail = false; @endphp
|
||||
|
||||
@auth
|
||||
@if(Auth::user()->home_top_design)
|
||||
@php $showThumbnails = true; @endphp
|
||||
@php $isThumbnail = true; @endphp
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
<!--Tabs content-->
|
||||
<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>
|
||||
@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 ">
|
||||
<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">
|
||||
@@ -41,7 +41,7 @@
|
||||
</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">
|
||||
@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 ">
|
||||
<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">
|
||||
@@ -50,7 +50,7 @@
|
||||
</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">
|
||||
@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 ">
|
||||
<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">
|
||||
|
||||
@@ -1,47 +1,365 @@
|
||||
<x-app-layout>
|
||||
<div class="container my-24 mx-auto md:px-6 z-10 relative">
|
||||
<section class="mb-32 text-center">
|
||||
<div class="flex justify-center pb-10">
|
||||
<img src="/images/cropped-HS-1-270x270.webp" class="max-w-[150px]" alt="hstream.moe Logo" />
|
||||
<div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
|
||||
{{-- Header Section --}}
|
||||
<section class="text-center mb-10 md:mb-14">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img
|
||||
src="/images/cropped-HS-1-270x270.webp"
|
||||
alt="hstream.moe Logo"
|
||||
class="max-w-[120px] w-full h-auto rounded-xl shadow-lg hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 lg:gap-x-12">
|
||||
<div class="mb-12 md:mb-0">
|
||||
<div class="mb-6 inline-block rounded-md bg-white dark:bg-neutral-950 p-4 text-sky-500">
|
||||
<i class="fa-solid fa-eye text-3xl"> {{ number_format($viewCount) }}</i>
|
||||
</div>
|
||||
<h5 class="text-lg font-medium dark:text-neutral-300">
|
||||
total views
|
||||
</h5>
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
|
||||
Site Statistics
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
|
||||
A comprehensive overview of hstream.moe's content and community activity
|
||||
</p>
|
||||
<div class="mt-5 flex justify-center">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live data · Updated hourly
|
||||
</div>
|
||||
<div class="mb-12 md:mb-0">
|
||||
<div class="b-6 inline-block rounded-md bg-white dark:bg-neutral-950 p-4 text-sky-500">
|
||||
<i class="fa-solid fa-video text-3xl"> {{ $episodeCount }}</i>
|
||||
</div>
|
||||
<h5 class="text-lg font-medium dark:text-neutral-300">
|
||||
episodes on this site
|
||||
</h5>
|
||||
</div>
|
||||
<div class="mb-12 md:mb-0">
|
||||
<div class="mb-6 inline-block rounded-md bg-white dark:bg-neutral-950 p-4 text-rose-600">
|
||||
<i class="fa-solid fa-list text-3xl"> {{ $hentaiCount }}</i>
|
||||
</div>
|
||||
<h5 class="text-lg font-medium dark:text-neutral-300">
|
||||
hentais on this site
|
||||
</h5>
|
||||
</div>
|
||||
<div class="mb-12 md:mb-0">
|
||||
<div class="mb-6 inline-block rounded-md bg-white dark:bg-neutral-950 p-4 text-rose-600">
|
||||
<i class="fa-solid fa-clock text-3xl"> {{ number_format($viewCount * 6) }}</i>
|
||||
</div>
|
||||
<h5 class="text-lg font-medium dark:text-neutral-300">
|
||||
estimated minutes of watch time
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center dark:bg-neutral-950 bg-gray-50 rounded-xl md:m-11 hidden md:block">
|
||||
<canvas id="monthlyChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Primary Stats Grid --}}
|
||||
<section class="mb-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Total Views --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-sky-400/10 dark:bg-sky-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-eye text-sky-600 dark:text-sky-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $viewCount }}">
|
||||
{{ number_format($viewCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Views</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Episodes --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-violet-400/10 dark:bg-violet-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-video text-violet-600 dark:text-violet-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $episodeCount }}">
|
||||
{{ number_format($episodeCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Series --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-rose-400/10 dark:bg-rose-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-rose-100 dark:bg-rose-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-list text-rose-600 dark:text-rose-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $hentaiCount }}">
|
||||
{{ number_format($hentaiCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Series</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Users --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-400/10 dark:bg-emerald-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-users text-emerald-600 dark:text-emerald-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $userCount }}">
|
||||
{{ number_format($userCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Registered Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Secondary Stats Grid --}}
|
||||
<section class="mb-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Total Likes --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-heart text-red-500 dark:text-red-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $likeCount }}">
|
||||
{{ number_format($likeCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Likes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Comments --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-comments text-amber-600 dark:text-amber-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $commentCount }}">
|
||||
{{ number_format($commentCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Comments</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Downloads --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-download text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $downloadCount }}">
|
||||
{{ number_format($downloadCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Avg Views Per Episode --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-chart-simple text-orange-600 dark:text-orange-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $avgViewsPerEpisode }}">
|
||||
{{ number_format($avgViewsPerEpisode) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Avg Views / Episode</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}}
|
||||
<section class="mb-10">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Today's Views --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-sky-50 to-sky-100/50 dark:from-sky-950/40 dark:to-sky-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-sky-200/50 dark:border-sky-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-sky-600 dark:text-sky-400 font-semibold uppercase tracking-wider mb-2">Today</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $todayViews }}">
|
||||
{{ number_format($todayViews) }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">views so far</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- This Week's Views with Trend --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-violet-50 to-violet-100/50 dark:from-violet-950/40 dark:to-violet-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-violet-200/50 dark:border-violet-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-violet-600 dark:text-violet-400 font-semibold uppercase tracking-wider mb-2">Views This Week</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $weeklyViews }}">
|
||||
{{ number_format($weeklyViews) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@php
|
||||
$trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0;
|
||||
@endphp
|
||||
@if($trend > 0)
|
||||
<i class="fa-solid fa-arrow-trend-up text-green-500 text-xs"></i>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 font-semibold">{{ $trend }}% vs last week</span>
|
||||
@elseif($trend < 0)
|
||||
<i class="fa-solid fa-arrow-trend-down text-red-500 text-xs"></i>
|
||||
<span class="text-xs text-red-600 dark:text-red-400 font-semibold">{{ abs($trend) }}% vs last week</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-500 dark:text-neutral-400 font-medium">same as last week</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- New Episodes This Week --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-950/40 dark:to-emerald-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-emerald-200/50 dark:border-emerald-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider mb-2">New This Week</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $newEpisodesThisWeek }}">
|
||||
{{ number_format($newEpisodesThisWeek) }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">episodes added</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 4K / 48fps Content --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-rose-50 to-rose-100/50 dark:from-rose-950/40 dark:to-rose-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-rose-200/50 dark:border-rose-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wider mb-2">High Quality</p>
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodes4k }}">{{ number_format($episodes4k) }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-neutral-400">4K</span>
|
||||
<span class="text-gray-300 dark:text-neutral-700">/</span>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodesUHD48 }}">{{ number_format($episodesUHD48) }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-neutral-400">48fps</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">4K & 48fps content</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Views Chart Section --}}
|
||||
<section class="mb-10">
|
||||
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white">Views Over Time</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400">Daily views for the past 28 days</p>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Chart wrapper with skeleton loader --}}
|
||||
<div class="relative min-h-[300px] md:min-h-[350px]">
|
||||
{{-- Skeleton Loader --}}
|
||||
<div id="chart-skeleton" class="absolute inset-0 flex flex-col justify-end px-1 pb-6 z-10">
|
||||
<div class="flex items-end justify-between gap-1 md:gap-2 h-full">
|
||||
@for ($i = 0; $i < 28; $i++)
|
||||
@php
|
||||
$heights = [55, 40, 65, 35, 60, 45, 70, 50, 75, 38, 58, 42, 80, 48, 68, 33, 62, 44, 72, 52, 55, 40, 65, 58, 78, 46, 63, 50];
|
||||
$h = $heights[$i % count($heights)];
|
||||
@endphp
|
||||
<div class="flex-1 flex flex-col items-center justify-end gap-1">
|
||||
<div class="w-full rounded-md bg-gray-200 dark:bg-neutral-800 animate-pulse" style="height: {{ $h }}%; min-height: 8px;"></div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
{{-- Shimmer overlay --}}
|
||||
<div class="absolute inset-0 shimmer-overlay rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
{{-- Actual Chart Canvas --}}
|
||||
<canvas id="monthlyChart" class="w-full h-[300px] md:h-[350px] relative z-0 opacity-0 transition-opacity duration-500"></canvas>
|
||||
|
||||
{{-- Error State --}}
|
||||
<div id="chart-error" class="absolute inset-0 hidden flex-col items-center justify-center bg-white/90 dark:bg-neutral-900/90 rounded-lg z-20">
|
||||
<div class="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-3">
|
||||
<i class="fa-solid fa-triangle-exclamation text-red-500 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-neutral-300 font-medium">Unable to load chart data</p>
|
||||
<button onclick="retryChart()" class="mt-3 px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-700 rounded-lg transition-colors">
|
||||
<i class="fa-solid fa-rotate mr-1"></i> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile legend --}}
|
||||
<div class="flex sm:hidden items-center justify-center gap-3 mt-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Bottom Section: Top Tags --}}
|
||||
@if($topTags->isNotEmpty())
|
||||
<section class="mb-8">
|
||||
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Most Popular Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($topTags as $tag)
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold
|
||||
bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-neutral-300
|
||||
hover:bg-rose-100 dark:hover:bg-rose-900/40 hover:text-rose-700 dark:hover:text-rose-400
|
||||
transition-colors duration-200 cursor-default">
|
||||
#{{ $tag->name }}
|
||||
<span class="text-[10px] text-gray-400 dark:text-neutral-500">{{ number_format($tag->count) }}</span>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Footer Note --}}
|
||||
<div class="text-center text-xs text-gray-400 dark:text-neutral-600">
|
||||
Statistics are cached and update periodically. Data shown may not reflect real-time changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Animated Counter Script --}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const animateCounters = () => {
|
||||
document.querySelectorAll('.stat-counter').forEach(counter => {
|
||||
const target = parseInt(counter.dataset.target, 10);
|
||||
if (isNaN(target)) return;
|
||||
|
||||
const duration = 1500;
|
||||
const start = performance.now();
|
||||
const startVal = 0;
|
||||
|
||||
const step = (currentTime) => {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.round(startVal + (target - startVal) * eased);
|
||||
|
||||
const formatted = new Intl.NumberFormat().format(current);
|
||||
counter.textContent = formatted;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
};
|
||||
|
||||
// Use IntersectionObserver to trigger counters when visible
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounters();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
const firstCounter = document.querySelector('.stat-counter');
|
||||
if (firstCounter) {
|
||||
observer.observe(firstCounter);
|
||||
} else {
|
||||
// Fallback
|
||||
animateCounters();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@vite(['resources/js/stats.js'])
|
||||
</x-app-layout>
|
||||
</x-app-layout>
|
||||
@@ -4,7 +4,7 @@
|
||||
@include('partials.head')
|
||||
|
||||
<body class="font-sans antialiased">
|
||||
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
|
||||
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-950">
|
||||
@include('layouts.navigation')
|
||||
|
||||
<!-- Page Heading -->
|
||||
|
||||
@@ -1,77 +1,99 @@
|
||||
<footer class="bg-white z-10 rounded-lg shadow dark:bg-neutral-950 m-4 mb-0 mt-auto">
|
||||
<div class="w-full xl:max-w-[95%] 2xl:max-w-[84%] mx-auto p-4">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<a href="https://hstream.moe/" class="flex items-center mb-4 sm:mb-0">
|
||||
<img src="/images/cropped-HS-1-192x192.webp" class="h-8 mr-3" alt="hstream.moe Logo" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">hstream.moe</span>
|
||||
</a>
|
||||
<ul class="flex flex-wrap items-center mb-6 text-sm font-medium text-gray-500 sm:mb-0 dark:text-gray-400">
|
||||
<li>
|
||||
<a href="{{ route('contact.index') }}" class="mr-4 hover:underline md:mr-6 "><i
|
||||
class="fa-solid fa-message"></i> Contact</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ config('discord.invite_link') }}" class="mr-4 hover:underline md:mr-6 "><i
|
||||
class="fa-brands fa-discord"></i> Discord</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('home.stats') }}" class="mr-4 hover:underline md:mr-6 "><i
|
||||
class="fa-solid fa-chart-simple"></i> Stats</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Links to friendly sites -->
|
||||
<ul class="flex flex-wrap items-center mb-6 text-sm font-medium text-gray-500 sm:mb-0 dark:text-gray-400">
|
||||
<li>
|
||||
<a target="_blank" href="https://everythingmoe.com/"
|
||||
class="mr-4 hover:underline md:mr-6">everythingmoe.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://theindex.moe/"
|
||||
class="mr-4 hover:underline md:mr-6">theindex.moe</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://www.squid-board.org/"
|
||||
class="mr-4 hover:underline md:mr-6">squidboard.org</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://hentaizilla.com/"
|
||||
class="hover:underline md:mr-6">hentaizilla.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://hentaipulse.com/"
|
||||
class="hover:underline md:mr-6">hentaipulse.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://hentaisites.com/"
|
||||
class="hover:underline md:mr-6">hentaisites.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://zhentube.com/"
|
||||
class="hover:underline md:mr-6">zhentube.com</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-wrap items-center mb-6 text-sm font-medium text-gray-500 sm:mb-0 dark:text-gray-400">
|
||||
<li>
|
||||
<a class="hover:underline md:mr-6 cursor-pointer" data-te-toggle="modal"
|
||||
data-te-target="#modalLanguage">Language</a>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="bg-white z-10 rounded-xl shadow-lg dark:bg-neutral-950 m-4 mb-0 mt-auto">
|
||||
<div class="w-full max-w-7xl mx-auto px-4 py-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center md:justify-start">
|
||||
<a href="https://hstream.moe/" class="flex items-center">
|
||||
<img src="/images/cropped-HS-1-192x192.webp"
|
||||
class="h-10 w-10 mr-3 rounded-lg object-cover"
|
||||
alt="hstream.moe Logo" />
|
||||
<span class="text-xl font-bold whitespace-nowrap text-gray-600 dark:text-white">hstream.moe</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (!Session::has('alert.config'))
|
||||
<script src="{{ asset('vendor/sweetalert/sweetalert.all.js') }}"></script>
|
||||
@endif
|
||||
@if (config('sweetalert.theme') != 'default')
|
||||
<link href="https://cdn.jsdelivr.net/npm/@sweetalert2/theme-{{ config('sweetalert.theme') }}"
|
||||
rel="stylesheet">
|
||||
@endif
|
||||
@include('sweetalert::alert')
|
||||
<!-- Main Navigation -->
|
||||
<div class="flex justify-center">
|
||||
<ul class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
<li>
|
||||
<a href="{{ route('contact.index') }}"
|
||||
class="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||
<i class="fa-solid fa-message"></i> Contact
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ config('discord.invite_link') }}"
|
||||
class="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||
<i class="fa-brands fa-discord"></i> Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('home.stats') }}"
|
||||
class="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||
<i class="fa-solid fa-chart-simple"></i> Stats
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<button data-te-toggle="modal"
|
||||
data-te-target="#modalLanguage"
|
||||
class="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-blue-600 transition-colors dark:text-gray-300">
|
||||
<i class="fa-solid fa-globe"></i> Language
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friendly Sites -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 text-center">Friendly Sites</h3>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
@foreach([
|
||||
'everythingmoe.com' => 'https://everythingmoe.com/',
|
||||
'theindex.moe' => 'https://theindex.moe/',
|
||||
'squidboard.org' => 'https://www.squid-board.org/',
|
||||
'hentaizilla.com' => 'https://hentaizilla.com/',
|
||||
'hentaipulse.com' => 'https://hentaipulse.com/',
|
||||
'hentaisites.com' => 'https://hentaisites.com/',
|
||||
'zhentube.com' => 'https://zhentube.com/'
|
||||
] as $name => $url)
|
||||
<a href="{{ $url }}"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors dark:text-gray-300">
|
||||
{{ $name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="m-4 w-full max-w-7xl mx-auto">
|
||||
<div class="text-xs text-gray-500 text-center dark:text-gray-400">
|
||||
<p>Render time: {{ number_format(microtime(true) - (defined('LARAVEL_START') ? LARAVEL_START : request()->server('REQUEST_TIME_FLOAT')), 3) }} seconds |
|
||||
Memory usage: {{ number_format(memory_get_peak_usage(true) / 1048576, 2) }} MB |
|
||||
Git: <a href="https://gitea.hstream.moe/w33b/hstream/commits/branch/main"
|
||||
target="_blank"
|
||||
class="hover:text-blue-600 transition-colors">
|
||||
{{ \App\Helpers\GitHelper::shortCommit() }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SweetAlert Scripts -->
|
||||
@if (!Session::has('alert.config'))
|
||||
<script src="{{ asset('vendor/sweetalert/sweetalert.all.js') }}"></script>
|
||||
@endif
|
||||
@if (config('sweetalert.theme') != 'default')
|
||||
<link href="https://cdn.jsdelivr.net/npm/@sweetalert2/theme-{{ config('sweetalert.theme') }}" rel="stylesheet">
|
||||
@endif
|
||||
@include('sweetalert::alert')
|
||||
|
||||
<!-- Modals -->
|
||||
@include('modals.language-selector')
|
||||
|
||||
<div class="m-2 w-full mx-auto">
|
||||
<div class="text-sm text-gray-500 text-center">
|
||||
<p>Render time: {{ number_format(microtime(true) - (defined('LARAVEL_START') ? LARAVEL_START : request()->server('REQUEST_TIME_FLOAT')), 3) }} seconds | Memory usage: {{ number_format(memory_get_peak_usage(true) / 1048576, 2) }} MB | Git: <a href="https://gitea.hstream.moe/w33b/hstream/commits/branch/main" target="_blank">{{ \App\Helpers\GitHelper::shortCommit() }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Thumbnail hover -->
|
||||
@vite(['resources/js/preview.js'])
|
||||
@@ -4,7 +4,7 @@
|
||||
@include('partials.head')
|
||||
|
||||
<body class="font-sans antialiased">
|
||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-neutral-900">
|
||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-neutral-950">
|
||||
<div>
|
||||
<a href="/">
|
||||
<x-application-logo class="w-24 h-24 fill-current text-gray-500" />
|
||||
|
||||
@@ -1,60 +1,291 @@
|
||||
<div>
|
||||
<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">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-x-auto rounded-lg w-3/6">
|
||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
|
||||
x-data="{
|
||||
showConfirm: false,
|
||||
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">×</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">
|
||||
<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>
|
||||
<th scope="col" class="px-6 py-3 text-center">
|
||||
User
|
||||
<input
|
||||
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 scope="col" class="px-4 py-3 w-10">
|
||||
<input type="checkbox" wire:model.live="selectPage"
|
||||
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>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
Comment
|
||||
<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 scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||
wire:click="sortBy('user_id')">
|
||||
<div class="flex items-center gap-1">
|
||||
Author
|
||||
@if($sortField === 'user_id')
|
||||
<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 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 scope="col" class="px-6 py-3">
|
||||
Actions
|
||||
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||
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 scope="col" class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($comments as $comment)
|
||||
<tr wire:key="comment-{{ $comment->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
|
||||
<td class="px-6 py-4">
|
||||
{{ $comment->user->name }}
|
||||
@forelse($comments as $comment)
|
||||
<tr wire:key="comment-{{ $comment->id }}"
|
||||
class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
|
||||
<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>
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
||||
{{ $comment->body }}
|
||||
</th>
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
||||
{{ $comment->created_at }}
|
||||
</th>
|
||||
<td class="px-6 py-4">
|
||||
<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">
|
||||
Delete
|
||||
</button>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($comment->user)
|
||||
<img src="{{ $comment->user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $comment->user->name }}</span>
|
||||
@if($comment->user->hasRole(\App\Enums\UserRole::BANNED))
|
||||
<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>
|
||||
@endif
|
||||
</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>
|
||||
</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>
|
||||
</table>
|
||||
</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>
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</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%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-x-auto rounded-lg w-3/6">
|
||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
|
||||
x-data="{
|
||||
showConfirm: false,
|
||||
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">×</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">
|
||||
<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>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
ID
|
||||
</th>
|
||||
<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 scope="col" class="px-4 py-3 w-10">
|
||||
<input type="checkbox" wire:model.live="selectPage"
|
||||
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>
|
||||
@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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($users as $user)
|
||||
<tr wire:key="user-{{ $user->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ $user->id }}
|
||||
</th>
|
||||
<td class="px-6 py-4">
|
||||
{{ $user->discord_id ?? 'n/a' }}
|
||||
@forelse($users as $user)
|
||||
<tr wire:key="user-{{ $user->id }}"
|
||||
class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
|
||||
<td class="px-4 py-3">
|
||||
<input type="checkbox" wire:model.live="selected" value="{{ $user->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 class="px-6 py-4">
|
||||
{{ $user->name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ $user->hasRole(\App\Enums\UserRole::SUPPORTER) ? 'Yes' : 'No' }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ $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
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $user->id }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $user->discord_id ?? 'n/a' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button wire:click="viewUser({{ $user->id }})"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-2">
|
||||
<img src="{{ $user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover">
|
||||
{{ $user->name }}
|
||||
</button>
|
||||
</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>
|
||||
@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>
|
||||
</table>
|
||||
</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>
|
||||
{{ $users->links('pagination::tailwind') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,121 +1,253 @@
|
||||
<div>
|
||||
<div class="flex" id="comment-{{ $comment->id }}">
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<img class="h-10 w-10 rounded-full" src="{{ $comment->user->getAvatar() }}" alt="{{ $comment->user->name }}">
|
||||
<div id="comment-{{ $comment->id }}">
|
||||
|
||||
<div class="group flex gap-4">
|
||||
|
||||
{{-- Avatar --}}
|
||||
<div class="shrink-0">
|
||||
@if($comment->isDeletedByModerator())
|
||||
<img
|
||||
class="h-10 w-10 rounded-full object-cover opacity-60"
|
||||
src="{{ asset('images/default-avatar.webp') }}"
|
||||
alt="Deleted comment"
|
||||
>
|
||||
@else
|
||||
<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
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex gap-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ $comment->user->name }}</p>
|
||||
@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::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 ($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
|
||||
</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
|
||||
<span data-te-toggle="tooltip" title="Please login to like the episode" class="text-gray-800 cursor-pointer dark:text-gray-200">
|
||||
<i class="fa-regular fa-heart"></i> {{ $comment->likeCount() }}
|
||||
</span>
|
||||
@endguest
|
||||
{{-- Content --}}
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
@auth
|
||||
<!-- Like Button -->
|
||||
<button class="text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap" wire:click="like">
|
||||
@if ($liked)
|
||||
<i class="fa-solid fa-heart text-rose-600"></i> {{ $likeCount }}
|
||||
@else
|
||||
<i class="fa-solid fa-heart"></i> {{ $likeCount }}
|
||||
@endif
|
||||
</button>
|
||||
@endauth
|
||||
<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">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
|
||||
@if($comment->isDeletedByModerator())
|
||||
|
||||
<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>
|
||||
|
||||
@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
|
||||
|
||||
@can ('update', $comment)
|
||||
<button wire:click="$toggle('isEditing')" type="button" class="text-gray-900 dark:text-gray-100 font-medium">
|
||||
Edit
|
||||
</button>
|
||||
@endcan
|
||||
{{-- 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
|
||||
|
||||
@can ('destroy', $comment)
|
||||
<button x-data="{
|
||||
confirmCommentDeletion () {
|
||||
if (window.confirm('Are you sure you want to delete this comment?')) {
|
||||
@this.call('deleteComment');
|
||||
}
|
||||
}
|
||||
}"
|
||||
@click="confirmCommentDeletion"
|
||||
type="button"
|
||||
class="text-gray-900 dark:text-gray-100 font-medium"
|
||||
@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
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
|
||||
<span class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ $comment->presenter()->relativeCreatedAt() }}
|
||||
</span>
|
||||
|
||||
{{-- Like --}}
|
||||
@guest
|
||||
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
{{ $comment->likeCount() }}
|
||||
</span>
|
||||
@endguest
|
||||
|
||||
@auth
|
||||
<button
|
||||
wire:click="like"
|
||||
class="flex items-center gap-1 text-neutral-500 transition hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
@if ($liked)
|
||||
<i class="fa-solid fa-heart text-rose-600"></i>
|
||||
@else
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
@endif
|
||||
|
||||
{{ $likeCount }}
|
||||
</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>
|
||||
|
||||
{{-- 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 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>
|
||||
@@ -1,60 +1,93 @@
|
||||
<section>
|
||||
<div class="bg-white dark:bg-neutral-800 shadow sm:rounded-lg sm:overflow-hidden">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-400/40">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-200">Comments</h2>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Comment Input -->
|
||||
<div class="bg-gray-50 dark:bg-neutral-800 px-4 py-6 sm:px-6">
|
||||
@auth
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<img class="h-10 w-10 rounded-full" src="{{ auth()->user()->getAvatar() }}" alt="{{ auth()->user()->name }}">
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<form wire:submit.prevent="postComment">
|
||||
<div>
|
||||
<label for="comment" class="sr-only">Comment body</label>
|
||||
<textarea id="comment" name="comment" rows="3"
|
||||
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
|
||||
@error('newCommentState.body') border-red-500 @enderror"
|
||||
placeholder="Write something" wire:model.defer="newCommentState.body"></textarea>
|
||||
@error('newCommentState.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>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
<div id="comments" class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
|
||||
|
||||
@guest
|
||||
<p class="text-gray-900 dark:text-gray-200">Log in to comment.</p>
|
||||
@endguest
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
{{-- 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>
|
||||
|
||||
{{-- Comment Form --}}
|
||||
<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
|
||||
<div class="flex gap-4">
|
||||
<img
|
||||
class="h-11 w-11 rounded-full object-cover ring-2 ring-white dark:ring-neutral-800"
|
||||
src="{{ auth()->user()->getAvatar() }}"
|
||||
alt="{{ auth()->user()->name }}"
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<form wire:submit.prevent="postComment" class="space-y-4">
|
||||
|
||||
<div>
|
||||
<label for="comment" class="sr-only">
|
||||
Comment body
|
||||
</label>
|
||||
|
||||
<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')
|
||||
<p class="mt-2 text-sm text-red-500">
|
||||
{{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
@guest
|
||||
<div class="rounded-xl border border-dashed border-neutral-300 p-6 text-center dark:border-neutral-700">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Log in to join the discussion.
|
||||
</p>
|
||||
</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>
|
||||
</section>
|
||||
@@ -8,9 +8,17 @@
|
||||
</div>
|
||||
<div class="flex flex-col text-center w-full">
|
||||
@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
|
||||
<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
|
||||
<p class="text-xs">{{ $fileExtension }} MKV {{ $fileSize ?? '' }}</p>
|
||||
<p class="text-xs" id="count-{{ $downloadId }}">Downloaded {{ $downloadCount }} times</p>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<div>
|
||||
@if (Auth::check())
|
||||
<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
|
||||
<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">
|
||||
@if ($liked)
|
||||
<i class="fa-solid fa-heart pr-[4px] text-rose-600"></i> {{ $likeCount }}
|
||||
@else
|
||||
<i class="fa-regular fa-heart pr-[4px]"></i> {{ $likeCount }}
|
||||
@endif
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="py-24">
|
||||
<div class="py-10">
|
||||
<div class="mx-auto sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||
@include('livewire.partials.search-filter')
|
||||
</div>
|
||||
@@ -24,5 +24,4 @@
|
||||
</div>
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@vite(['resources/js/preview.js'])
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
class="max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-lg transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
|
||||
<div class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-neutral-800">
|
||||
class="w-full max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-xl transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-neutral-800">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 font-medium">
|
||||
@if($episodes->count())
|
||||
{{ __('Search result for ') }} “{{ $query ?: $navSearch }}”
|
||||
{{ __('Search result for ') }}<span class="text-rose-600 dark:text-rose-400 font-semibold">"{{ $query ?: $navSearch }}"</span>
|
||||
@else
|
||||
{{ __('No results') }}
|
||||
@endif
|
||||
@@ -60,46 +61,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- content area: responsive grid --}}
|
||||
<div class="p-4">
|
||||
{{-- Results List --}}
|
||||
<div>
|
||||
@if($episodes->count())
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
<ul class="divide-y divide-gray-100 dark:divide-neutral-800" role="listbox">
|
||||
@foreach($episodes as $episode)
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}" class="group block rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-950 border border-transparent hover:border-gray-200 dark:hover:border-neutral-700 shadow-sm hover:shadow-md transition">
|
||||
<div class="relative aspect-video">
|
||||
<img
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"
|
||||
>
|
||||
<span class="absolute right-0 top-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs font-semibold rounded-tr rounded-bl-xl px-2 py-1">{{ $episode->getResolution() }}</span>
|
||||
<div class="absolute left-0 bottom-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs rounded-tr-xl px-2 py-1 font-medium">
|
||||
<i class="fa-regular fa-eye mr-1"></i> {{ $episode->viewCountFormatted() }}
|
||||
<i class="fa-regular fa-heart ml-2"></i> {{ $episode->likeCount() }}
|
||||
<li role="option" aria-selected="false">
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}"
|
||||
class="flex items-center gap-4 px-5 py-2 group/row hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
|
||||
>
|
||||
{{-- Left: Cover Image --}}
|
||||
<div class="flex-shrink-0 relative">
|
||||
<div class="w-16 h-[6rem] rounded-lg overflow-hidden ring-1 ring-gray-200/80 dark:ring-neutral-700/80 shadow-sm group-hover/row:ring-rose-300 dark:group-hover/row:ring-rose-700 group-hover/row:shadow-md transition-all duration-200">
|
||||
<img
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
class="object-cover w-full h-full group-hover/row:scale-105 transition-transform duration-300"
|
||||
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 class="p-3">
|
||||
<h3 class="text-sm font-semibold truncate text-gray-900 dark:text-white">{{ $episode->title }} - {{ $episode->episode }}</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> {{ \Illuminate\Support\Str::limit($episode->description ?? '', 80) }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Center: Title, Publisher, Tags --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
{{-- Row 1: Title --}}
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate group-hover/row:text-rose-700 dark:group-hover/row:text-rose-400 transition-colors duration-150">
|
||||
{{ $episode->title }}
|
||||
</h3>
|
||||
|
||||
{{-- Row 2: Publisher --}}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
|
||||
<i class="fa-regular fa-building mr-1 text-[0.65rem] opacity-70"></i>
|
||||
{{ $episode->studio?->name ?? __('Unknown') }}
|
||||
</p>
|
||||
|
||||
{{-- Row 3: Tags --}}
|
||||
<div class="mt-1.5 flex items-center gap-1 flex-wrap">
|
||||
@php
|
||||
$tags = $episode->tagNames();
|
||||
$visibleTags = array_slice($tags, 0, 3);
|
||||
$remainingCount = count($tags) - 3;
|
||||
@endphp
|
||||
@foreach($visibleTags as $tag)
|
||||
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-rose-100/80 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300 ring-1 ring-inset ring-rose-200/60 dark:ring-rose-800/60">
|
||||
{{ $tag }}
|
||||
</span>
|
||||
@endforeach
|
||||
@if($remainingCount > 0)
|
||||
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-gray-100 text-gray-500 dark:bg-neutral-800 dark:text-gray-400">
|
||||
+{{ $remainingCount }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right: View Count, Like Count --}}
|
||||
<div class="flex-shrink-0 flex flex-col items-end gap-1.5">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Views') }}">
|
||||
<i class="fa-regular fa-eye text-[0.65rem] opacity-70"></i>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $episode->viewCountFormatted() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Likes') }}">
|
||||
<i class="fa-regular fa-heart text-[0.65rem] opacity-70 text-rose-500 dark:text-rose-400"></i>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ number_format($episode->likeCount()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
{{-- Advanced Search card --}}
|
||||
<a href="{{ route('hentai.search', ['search' => $query]) }}" class="flex items-center justify-center rounded-xl border border-dashed border-gray-200 dark:border-neutral-700 p-6 hover:bg-gray-50 dark:hover:bg-neutral-900 transition">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-rose-600 mb-1">🔎</div>
|
||||
<div class="font-semibold text-sm dark:text-white">Advanced Search</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">View more results</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{-- Advanced Search footer --}}
|
||||
<li>
|
||||
<a href="{{ route('hentai.search', ['search' => $query]) }}"
|
||||
class="block px-5 py-3.5 text-center group/advanced hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 group-hover/advanced:text-rose-700 dark:group-hover/advanced:text-rose-400 transition-colors">
|
||||
{{ __('Advanced Search') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('View all results') }}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover/advanced:text-rose-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@else
|
||||
{{-- Empty state --}}
|
||||
<div class="py-12 text-center text-sm text-gray-600 dark:text-gray-300">
|
||||
<div class="mb-3">No results found for “{{ $query ?: $navSearch }}”</div>
|
||||
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-block px-4 py-2 rounded-lg bg-rose-700 text-white text-sm hover:bg-rose-800">Try advanced search</a>
|
||||
<div class="mb-3">{{ __('No results found for') }} <span class="font-semibold text-rose-600">"{{ $query ?: $navSearch }}"</span></div>
|
||||
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-rose-700 text-white text-sm font-medium hover:bg-rose-800 transition shadow-sm hover:shadow-md">
|
||||
<span>{{ __('Try advanced search') }}</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -108,4 +168,4 @@
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,90 +1,141 @@
|
||||
<!-- Search Filter -->
|
||||
<!-- Search Filters -->
|
||||
<div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800 dark:bg-neutral-950/70 space-y-4">
|
||||
|
||||
<!-- Filters Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="live-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
<!-- Search -->
|
||||
<div class="xl:col-span-2">
|
||||
<label for="live-search" class="sr-only">
|
||||
{{ __('search.search-hentai') }}
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Search Icon -->
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<input wire:model.live.debounce.600ms="search" type="search" id="live-search" class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" placeholder="{{ __('search.search-hentai') }}" required>
|
||||
<!-- Input -->
|
||||
<input
|
||||
wire:model.live.debounce.500ms="search"
|
||||
type="search"
|
||||
id="live-search"
|
||||
placeholder="{{ __('search.search-hentai') }}"
|
||||
class="w-full rounded-xl border border-neutral-300 bg-white py-5 pl-12 pr-12 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
|
||||
<div class="absolute right-0 top-[11px]" wire:loading>
|
||||
<svg aria-hidden="true" class="inline w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-pink-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
|
||||
</svg>
|
||||
<!-- Loading -->
|
||||
<div wire:loading.class="opacity-100" class="opacity-0">
|
||||
<div class="absolute inset-y-0 right-3 flex items-center">
|
||||
<svg class="h-5 w-5 animate-spin text-rose-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-90" fill="currentColor"
|
||||
d="M22 12a10 10 0 0 1-10 10V18a6 6 0 0 0 6-6h4Z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-sliders text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<p data-te-toggle="modal" data-te-target="#modalGenres" data-te-ripple-init data-te-ripple-color="light" id="genres-filter" class="block cursor-pointer w-full p-4 pl-10 text-sm text-gray-500 dark:text-gray-400 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:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
@if($tagcount === 0)
|
||||
Select Genres
|
||||
@elseif($tagcount === 1)
|
||||
Selected {{$tagcount }} Genre
|
||||
@elseif($tagcount > 1)
|
||||
Selected {{$tagcount }} Genres
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalGenres"
|
||||
class="group flex items-center gap-3 rounded-xl border border-neutral-300 bg-white px-4 py-3 text-left shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-sliders text-neutral-400 group-hover:text-rose-500"></i>
|
||||
|
||||
<!-- Genres Blacklist -->
|
||||
<div>
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-shield text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<p data-te-toggle="modal" data-te-target="#modalBlacklist" data-te-ripple-init data-te-ripple-color="light" id="blacklist-filter" class="block cursor-pointer w-full p-4 pl-10 text-sm text-gray-500 dark:text-gray-400 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:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
@if($blacklistcount === 0)
|
||||
Select Blacklist
|
||||
@elseif($blacklistcount === 1)
|
||||
Selected {{ $blacklistcount }} Blacklist Item
|
||||
@elseif($blacklistcount > 1)
|
||||
Selected {{ $blacklistcount }} Blacklist Items
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Genres
|
||||
</span>
|
||||
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
@if($tagcount === 0)
|
||||
Select Genres
|
||||
@elseif($tagcount === 1)
|
||||
1 Genre Selected
|
||||
@else
|
||||
{{ $tagcount }} Genres Selected
|
||||
@endif
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Blacklist -->
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalBlacklist"
|
||||
class="group flex items-center gap-3 rounded-xl border border-neutral-300 bg-white px-4 py-3 text-left shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-shield text-neutral-400 group-hover:text-rose-500"></i>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Blacklist
|
||||
</span>
|
||||
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
@if($blacklistcount === 0)
|
||||
Select Blacklist
|
||||
@elseif($blacklistcount === 1)
|
||||
1 Item Selected
|
||||
@else
|
||||
{{ $blacklistcount }} Items Selected
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Studios -->
|
||||
<div>
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-microphone-lines text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<p data-te-toggle="modal" data-te-target="#modalStudios" data-te-ripple-init data-te-ripple-color="light" id="studios-filter" class="block cursor-pointer w-full p-4 pl-10 text-sm text-gray-500 dark:text-gray-400 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:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
@if($studiocount === 0)
|
||||
Select Studios
|
||||
@elseif($studiocount === 1)
|
||||
Selected {{ $studiocount }} Studio
|
||||
@elseif($studiocount > 1)
|
||||
Selected {{ $studiocount }} Studios
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalStudios"
|
||||
class="group flex items-center gap-3 rounded-xl border border-neutral-300 bg-white px-4 py-3 text-left shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-microphone-lines text-neutral-400 group-hover:text-rose-500"></i>
|
||||
|
||||
<!-- Ordering -->
|
||||
<div class="grid grid-cols-2">
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-sort text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<select wire:model.live="order" class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Studios
|
||||
</span>
|
||||
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
@if($studiocount === 0)
|
||||
Select Studios
|
||||
@elseif($studiocount === 1)
|
||||
1 Studio Selected
|
||||
@else
|
||||
{{ $studiocount }} Studios Selected
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls -->
|
||||
<div class="mt-4 flex flex-col gap-4 border-t border-neutral-200 pt-4 dark:border-neutral-800 lg:flex-row lg:items-center lg:justify-between">
|
||||
|
||||
<!-- Selects -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
|
||||
<!-- Order -->
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-sort pointer-events-none absolute left-4 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-3 pl-11 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
<option value="recently-uploaded">{{ __('home.recently-uploaded') }}</option>
|
||||
@@ -95,28 +146,40 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative right-2 left-0 ml-2 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-list text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<select wire:model.live="view" class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
<!-- View -->
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-list pointer-events-none absolute left-4 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-3 pl-11 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="thumbnail">Thumbnail</option>
|
||||
<option value="poster">Poster</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
<div class="float-right pt-1">
|
||||
<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" wire:model.live="hideWatched" value="true" id="checkBoxHideWatched" />
|
||||
<label class="inline-block hover:cursor-pointer dark:text-white" for="checkBoxHideWatched">
|
||||
Hide watched
|
||||
</label>
|
||||
<!-- Auth Options -->
|
||||
@auth
|
||||
<label
|
||||
for="checkBoxHideWatched"
|
||||
class="flex cursor-pointer items-center gap-3 text-sm text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
<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>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
@include('modals.filter-genres')
|
||||
@include('modals.filter-studios')
|
||||
@include('modals.filter-blacklist')
|
||||
|
||||
@@ -1,86 +1,3 @@
|
||||
<div wire:key="episode-{{ $episode->id }}">
|
||||
@if ($searchIsJpn)
|
||||
<div class="relative p-1 mb-14 w-full transition duration-300 ease-in-out md:p-2 md:hover:-translate-y-1 md:hover:scale-110"
|
||||
data-thumbs="{{ optional($episode->gallery)->pluck('thumbnail_url') }}">
|
||||
@else
|
||||
<div class="relative p-1 mb-8 w-full transition duration-300 ease-in-out md:p-2 md:hover:-translate-y-1 md:hover:scale-110"
|
||||
data-thumbs="{{ optional($episode->gallery)->pluck('thumbnail_url') }}">
|
||||
@endif
|
||||
<a class="hover:text-blue-600" href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
|
||||
<div class="absolute w-[95%] top-[38%] text-center z-10">
|
||||
<svg aria-hidden="true"
|
||||
class="inline mr-2 w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-pink-600"
|
||||
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@switch(true)
|
||||
@case($view === 'thumbnail')
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="500"
|
||||
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
|
||||
src="{{ optional($episode->gallery->first())->thumbnail_url }}">
|
||||
@if ($episode->hasAutoTrans())
|
||||
<p
|
||||
class="absolute right-1 md:right-2 bottom-1 md:bottom-2 bg-blue-600/80 !text-white rounded-tl-lg rounded-br-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-closed-captioning"></i> Multi-Subs
|
||||
</p>
|
||||
@endif
|
||||
@break
|
||||
|
||||
@case($view === 'poster')
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="400"
|
||||
class="block relative rounded-lg object-cover object-center aspect-[11/16] z-20"
|
||||
src="{{ $episode->cover_url }}">
|
||||
@break
|
||||
|
||||
@endswitch
|
||||
|
||||
@php $problematic = cache()->rememberForever('episodeProblematic'.$episode->id, fn () => $episode->getProblematicTags()); @endphp
|
||||
@if (!empty($problematic))
|
||||
<p
|
||||
class="absolute left-1 md:left-2 top-1 md:top-2 bg-red-700/70 !text-white rounded-br-lg rounded-tl-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ $problematic }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (auth()->check() && $episode->userWatched(auth()->user()->id))
|
||||
<p
|
||||
class="absolute right-1 md:right-2 top-1 md:top-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<p
|
||||
class="absolute left-1 md:left-2 bottom-1 md:bottom-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i>
|
||||
{{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i> {{ $episode->commentCount() }}
|
||||
</p>
|
||||
@else
|
||||
<p
|
||||
class="absolute right-1 md:right-2 top-1 md:top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
{{ $episode->getResolution() }}</p>
|
||||
<p
|
||||
class="absolute left-1 md:left-2 bottom-1 md:bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
{{ $episode->viewCountFormatted() }}
|
||||
<i class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="absolute w-[95%] grid grid-cols-1 text-center">
|
||||
@if ($searchIsJpn)
|
||||
<p class="text-sm text-center text-black dark:text-white">{{ $episode->title }}
|
||||
({{ $episode->title_jpn }}) - {{ $episode->episode }}</p>
|
||||
@else
|
||||
<p class="text-sm text-center text-black dark:text-white">{{ $episode->title }} -
|
||||
{{ $episode->episode }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<x-episode-cover :episode="$episode" :view="$view" :displayjapanese="$searchIsJpn" />
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,65 @@
|
||||
src="{{ $playlist->user->getAvatar() }}">
|
||||
</div>
|
||||
<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">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 class="flex flex-col justify-center pl-4">
|
||||
<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>
|
||||
@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>
|
||||
|
||||
@@ -1,101 +1,108 @@
|
||||
<div>
|
||||
<div class="mx-auto max-w-5xl px-4 space-y-6">
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="p-4 bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow flex flex-col sm:flex-row gap-3">
|
||||
|
||||
<!-- Search -->
|
||||
<div class="space-y-5">
|
||||
{{-- Filters --}}
|
||||
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
{{-- Search --}}
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
wire:model.live.debounce.500ms="commentSearch"
|
||||
type="search"
|
||||
placeholder="Search comments..."
|
||||
class="w-full pl-10 pr-4 py-3 rounded-lg border-neutral-300 dark:text-neutral-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
wire:model.live.debounce.500ms="commentSearch"
|
||||
type="search"
|
||||
placeholder="Search comments..."
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-xl border-neutral-300 dark:text-neutral-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Order -->
|
||||
<select
|
||||
wire:model.live="order"
|
||||
class="px-4 py-3 rounded-lg border-neutral-300 dark:text-gray-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 min-w-[128px]"
|
||||
>
|
||||
<option value="created_at_desc">Newest</option>
|
||||
<option value="created_at_asc">Oldest</option>
|
||||
</select>
|
||||
{{-- 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="appearance-none pl-10 pr-8 py-2.5 rounded-xl border-neutral-300 dark:text-gray-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 min-w-[128px]"
|
||||
>
|
||||
<option value="created_at_desc">Newest</option>
|
||||
<option value="created_at_asc">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="space-y-4">
|
||||
@forelse ($comments as $comment)
|
||||
{{-- Comments --}}
|
||||
<div class="space-y-3">
|
||||
@forelse ($comments as $comment)
|
||||
|
||||
@php
|
||||
$model = $comment->commentable;
|
||||
$episode = $model instanceof \App\Models\Episode
|
||||
? $model
|
||||
: $model->episodes->first();
|
||||
@php
|
||||
$model = $comment->commentable;
|
||||
$episode = $model instanceof \App\Models\Episode
|
||||
? $model
|
||||
: $model->episodes->first();
|
||||
|
||||
$url = route('hentai.index', ['title' => $model->slug]);
|
||||
@endphp
|
||||
$url = route('hentai.index', ['title' => $model->slug]);
|
||||
@endphp
|
||||
|
||||
<a href="{{ $url }}#comment-{{ $comment->id }}"
|
||||
wire:key="comment-{{ $comment->id }}"
|
||||
class="block group">
|
||||
<a href="{{ $url }}#comment-{{ $comment->id }}"
|
||||
wire:key="comment-{{ $comment->id }}"
|
||||
class="block group">
|
||||
|
||||
<div class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
|
||||
<div class="rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 overflow-hidden">
|
||||
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="sm:w-48 shrink-0">
|
||||
<img
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"
|
||||
alt=""
|
||||
class="w-full h-40 sm:h-full object-cover"
|
||||
>
|
||||
{{-- Thumbnail --}}
|
||||
<div class="sm:w-44 shrink-0">
|
||||
<img
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"
|
||||
alt="{{ $episode->title ?? '' }}"
|
||||
class="w-full h-36 sm:h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 p-4 flex flex-col justify-between 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>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 p-4 flex flex-col justify-between">
|
||||
|
||||
<!-- Comment -->
|
||||
<div class="text-gray-800 dark:text-gray-200 text-sm line-clamp-3">
|
||||
{!! $comment->presenter()->markdownBody() !!}
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<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>
|
||||
{{-- Meta --}}
|
||||
<div class="flex items-center justify-between mt-2.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-solid fa-clock text-[10px]"></i>
|
||||
{{ $comment->presenter()->relativeCreatedAt() }}
|
||||
</span>
|
||||
<span class="text-rose-600 dark:text-rose-400 font-medium group-hover:underline">
|
||||
View comment
|
||||
</span>
|
||||
</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>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div>
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</div>
|
||||
|
||||
</a>
|
||||
@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-comment-slash text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No comments found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You haven't commented on anything yet.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div>
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,139 @@
|
||||
<div>
|
||||
<div class="md:ml-8 my-8 md:my-0 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[95%]">
|
||||
@include('livewire.partials.search-filter')
|
||||
</div>
|
||||
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
|
||||
<div class="relative md:ml-8 pt-5 mx-auto space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[95%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex justify-center">
|
||||
<div class="space-y-5">
|
||||
{{-- Slim Profile Filter --}}
|
||||
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
{{-- Search --}}
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-4 w-4 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
wire:model.live.debounce.400ms="search"
|
||||
type="search"
|
||||
placeholder="Search liked episodes..."
|
||||
class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-11 pr-4 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Order --}}
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
|
||||
<select
|
||||
wire:model.live="order"
|
||||
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
<option value="recently-uploaded">{{ __('home.recently-uploaded') }}</option>
|
||||
<option value="recently-released">{{ __('home.recently-released') }}</option>
|
||||
<option value="oldest-uploads">{{ __('search.oldest-uploads') }}</option>
|
||||
<option value="oldest-releases">{{ __('search.oldest-releases') }}</option>
|
||||
<option value="view-count">{{ __('search.view-count') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- View --}}
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-list pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
|
||||
<select
|
||||
wire:model.live="view"
|
||||
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="thumbnail">Thumbnail</option>
|
||||
<option value="poster">Poster</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Filter Buttons --}}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalGenres"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-sliders text-[10px]"></i>
|
||||
Genres
|
||||
@if($tagcount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $tagcount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalBlacklist"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-shield text-[10px]"></i>
|
||||
Blacklist
|
||||
@if($blacklistcount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $blacklistcount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalStudios"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-microphone-lines text-[10px]"></i>
|
||||
Studios
|
||||
@if($studiocount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $studiocount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Hide Watched --}}
|
||||
@auth
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 whitespace-nowrap">
|
||||
<input
|
||||
id="checkBoxHideWatched"
|
||||
type="checkbox"
|
||||
wire:model.live="hideWatched"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-rose-600 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
<span>Hide watched</span>
|
||||
</label>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modals --}}
|
||||
@include('modals.filter-genres')
|
||||
@include('modals.filter-studios')
|
||||
@include('modals.filter-blacklist')
|
||||
|
||||
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
|
||||
|
||||
{{-- 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')
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
@else
|
||||
<div class="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8 gap-2">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
@endif
|
||||
@forelse($episodes as $episode)
|
||||
@include('livewire.partials.search-result')
|
||||
@empty
|
||||
<div class="col-span-full">
|
||||
<p class="text-2xl w-52 pt-6">No results (╥﹏╥)</p>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-heart-crack text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No liked episodes</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start exploring and like what you enjoy!</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
</div>
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@vite(['resources/js/preview.js'])
|
||||
</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>
|
||||
@@ -1,28 +1,51 @@
|
||||
<div>
|
||||
<div class="relative mx-auto sm:px-6 lg:px-8 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
|
||||
wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
<ol class="border-l border-neutral-300 dark:border-neutral-500">
|
||||
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage" class="text-gray-900 dark:text-white">
|
||||
<div class="relative">
|
||||
{{-- Timeline --}}
|
||||
<ol class="relative border-l-2 border-neutral-300/70 dark:border-neutral-600/70 ml-3 sm:ml-4">
|
||||
@foreach ($watchedGrouped as $day => $episodes)
|
||||
<li>
|
||||
<div class="flex items-center pt-3 flex-start">
|
||||
<div class="-ml-[5px] mr-3 h-[9px] w-[9px] rounded-full bg-neutral-300 dark:bg-neutral-500">
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-300">
|
||||
<li class="mb-10 ml-6 sm:ml-8 last:mb-0">
|
||||
{{-- Timeline Dot --}}
|
||||
<span class="absolute flex items-center justify-center w-6 h-6 rounded-full -left-3 ring-2 ring-white dark:ring-neutral-950 bg-rose-600 dark:bg-rose-500">
|
||||
<i class="fa-solid fa-circle text-[6px] text-white"></i>
|
||||
</span>
|
||||
|
||||
{{-- Date Header --}}
|
||||
<div class="mb-4">
|
||||
<time class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold text-gray-600 dark:text-gray-300 bg-gray-100/70 dark:bg-neutral-800/70">
|
||||
<i class="fa-solid fa-calendar text-[10px] text-rose-500"></i>
|
||||
{{ $episodes->first()->created_at->diffForHumans(['parts' => 1]) }}
|
||||
</p>
|
||||
</time>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $episodes->count() }} {{ Str::plural('episode', $episodes->count()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
@foreach ($episodes as $episode)
|
||||
<div class="mt-2 mb-6 ml-4">
|
||||
<x-episode-thumbnail :episode="$episode->episode" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Episodes Grid --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
@foreach ($episodes as $episode)
|
||||
<x-episode-cover :episode="$episode->episode" view="thumbnail" />
|
||||
@endforeach
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</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>
|
||||
@@ -1,95 +1,83 @@
|
||||
@auth
|
||||
<!--Verically centered modal-->
|
||||
<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="modalAddToPlaylist" tabindex="-1" aria-labelledby="Playlist" aria-modal="true" role="dialog">
|
||||
<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-[30%]">
|
||||
<div class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalAddToPlaylist"
|
||||
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-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="__('Add to Playlist')" />
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
<!-- Add to existing playlist -->
|
||||
@php $playlists = Auth::user()->playlists; @endphp
|
||||
|
||||
@if (count($playlists) > 0)
|
||||
<!-- Add to existing playlist -->
|
||||
<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">
|
||||
@foreach($playlists as $playlist)
|
||||
<option value="{{ $playlist->id }}">
|
||||
{{ $playlist->name }} -
|
||||
{{ $playlist->is_private == 1 ? 'Private' : 'Public' }} -
|
||||
{{ $playlist->episodes->count() }} Episodes
|
||||
{{ $playlist->name }} -
|
||||
{{ $playlist->is_private == 1 ? 'Private' : 'Public' }} -
|
||||
{{ $playlist->episodes->count() }} Episodes
|
||||
{{ $playlist->episodes->contains('episode_id', $episode->id) ? '- Episode Already Added' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('playlist')" 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">
|
||||
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4 gap-3">
|
||||
<a
|
||||
data-te-modal-dismiss
|
||||
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">
|
||||
Cancel
|
||||
</a>
|
||||
<a id="playlist-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">
|
||||
<a
|
||||
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">
|
||||
Add
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
<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="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-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>
|
||||
|
||||
<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
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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">
|
||||
<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="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" />
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="playlist-visibility">Visibility:</label>
|
||||
<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="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-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">
|
||||
Create and Add Episode
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="comment-modal-{{ $comment->getKey() }}"
|
||||
tabindex="-1"
|
||||
aria-labelledby="exampleModalCenterTitle"
|
||||
aria-modal="true"
|
||||
role="dialog">
|
||||
<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-[40%]">
|
||||
<div class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('comments::comments.edit_comment')" />
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
|
||||
<form method="POST" action="{{ route('comments.update', $comment->getKey()) }}">
|
||||
@method('PUT')
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="message">@lang('comments::comments.update_your_message_here')</label>
|
||||
<textarea 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" name="message" rows="3">{{ $comment->comment }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<button
|
||||
type="button"
|
||||
id="modal-blacklist-filter-close-bottom"
|
||||
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">
|
||||
@lang('comments::comments.cancel')
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
id="modal-blacklist-filter-save"
|
||||
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-modal-dismiss
|
||||
data-te-ripple-init
|
||||
data-te-ripple-color="light">
|
||||
@lang('comments::comments.update')
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="reply-modal-{{ $comment->getKey() }}"
|
||||
tabindex="-1"
|
||||
aria-labelledby="exampleModalCenterTitle"
|
||||
aria-modal="true"
|
||||
role="dialog">
|
||||
<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-[40%]">
|
||||
<div class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('comments::comments.reply_to_comment')" />
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
|
||||
<form method="POST" action="{{ route('comments.reply', $comment->getKey()) }}">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="message">@lang('comments::comments.enter_your_message_here')</label>
|
||||
<textarea required 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" name="message" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<button
|
||||
type="button"
|
||||
id="modal-blacklist-filter-close-bottom"
|
||||
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">
|
||||
@lang('comments::comments.cancel')
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
id="modal-blacklist-filter-save"
|
||||
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-modal-dismiss
|
||||
data-te-ripple-init
|
||||
data-te-ripple-color="light">
|
||||
@lang('comments::comments.reply')
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,14 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalCreatePlaylist" tabindex="-1" aria-labelledby="Playlist" aria-modal="true" role="dialog">
|
||||
<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-[30%]">
|
||||
<div class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalCreatePlaylist"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalCreatePlaylist"
|
||||
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-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')" />
|
||||
|
||||
<!--Modal body-->
|
||||
@@ -10,25 +17,30 @@
|
||||
@csrf
|
||||
|
||||
<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/>
|
||||
<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" maxlength="30" 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>
|
||||
<div class="p-4">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4 gap-3">
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,4 +48,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,19 +1,26 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalDownload" tabindex="-1" aria-labelledby="Download" aria-modal="true" role="dialog">
|
||||
<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-[50%] 2xl:min-[576px]:max-w-[30%]">
|
||||
<div class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title='__("Download {$episode->title} - {$episode->episode}")' />
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalDownload"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalDownload"
|
||||
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-4xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<x-modal-header title="Download {{ $episode->title }} - {{ $episode->episode }}" />
|
||||
|
||||
@php
|
||||
$dldomains = config('hstream.download_domain');
|
||||
$dlDomainsBackup = config('hstream.asia_download_domain');
|
||||
@endphp
|
||||
@php
|
||||
$dldomains = config('hstream.download_domain');
|
||||
$dlDomainsBackup = config('hstream.asia_download_domain');
|
||||
@endphp
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
@include('modals.partials.download-guest')
|
||||
@include('modals.partials.download-authorized')
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
@include('modals.partials.download-guest')
|
||||
@include('modals.partials.download-authorized')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,165 +1,107 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalBlacklist" tabindex="-1" aria-labelledby="modalBlacklist" aria-modal="true" role="dialog">
|
||||
<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-[60%]">
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalBlacklist"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalBlacklistLabel"
|
||||
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="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('Blacklist')" />
|
||||
class="relative w-full max-w-6xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
<div>
|
||||
<h2
|
||||
id="modalGenresLabel"
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Genres & Filters
|
||||
</h2>
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
@php
|
||||
$taglist = \cache()->remember(
|
||||
'searchtags',
|
||||
300,
|
||||
fn() => Conner\Tagging\Model\Tag::where('count', '>', 0)->orderBy('slug', 'ASC')->get(),
|
||||
);
|
||||
$appearances = [
|
||||
'Loli',
|
||||
'Shota',
|
||||
'Milf',
|
||||
'Futanari',
|
||||
'Big Boobs',
|
||||
'Small Boobs',
|
||||
'Dark Skin',
|
||||
'Cosplay',
|
||||
'Elf',
|
||||
'Maid',
|
||||
'Nekomimi',
|
||||
'Nurse',
|
||||
'School Girl',
|
||||
'Succubus',
|
||||
'Teacher',
|
||||
'Trap',
|
||||
'Pregnant',
|
||||
'Glasses',
|
||||
'Swim Suit',
|
||||
'Ugly Bastard',
|
||||
'Monster',
|
||||
];
|
||||
$types = [
|
||||
'3D',
|
||||
'4K',
|
||||
'48Fps',
|
||||
'4K 48Fps',
|
||||
'Censored',
|
||||
'Uncensored',
|
||||
'Comedy',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Vanilla',
|
||||
'Ntr',
|
||||
'Pov',
|
||||
'Filmed',
|
||||
'X-Ray',
|
||||
];
|
||||
$actions = [
|
||||
'Anal',
|
||||
'Bdsm',
|
||||
'Facial',
|
||||
'Blow Job',
|
||||
'Boob Job',
|
||||
'Foot Job',
|
||||
'Hand Job',
|
||||
'Rimjob',
|
||||
'Inflation',
|
||||
'Masturbation',
|
||||
'Public Sex',
|
||||
'Rape',
|
||||
'Reverse Rape',
|
||||
'Threesome',
|
||||
'Orgy',
|
||||
'Gangbang',
|
||||
];
|
||||
@endphp
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Select tags to blacklist from your content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($taglist as $tag)
|
||||
@if (in_array($tag->name, $types) || in_array($tag->name, $appearances) || in_array($tag->name, $actions))
|
||||
@continue
|
||||
@endif
|
||||
<li class="inline-block m-1">
|
||||
<input class="m-5 hidden peer" wire:model="blacklist" type="checkbox"
|
||||
id="blacklist-{{ $tag->slug }}" name="blacklist[]" value="{{ $tag->slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="blacklist-{{ $tag->slug }}">{{ $tag->name }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
<!-- Actions -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Action
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($actions as $tag)
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="blacklist" type="checkbox"
|
||||
id="blacklist-{{ $slug }}" name="blacklist[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="blacklist-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
<!-- Character Appearance -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Appearance
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($appearances as $tag)
|
||||
@guest
|
||||
@php if ($tag === "Loli" || $tag === "Shota") continue; @endphp
|
||||
@endguest
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="blacklist" type="checkbox"
|
||||
id="blacklist-{{ $slug }}" name="blacklist[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="blacklist-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
|
||||
<!-- Video Types -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Type
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($types as $tag)
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="blacklist" type="checkbox"
|
||||
id="blacklist-{{ $slug }}" name="blacklist[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="blacklist-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!--Modal footer-->
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<button data-te-modal-dismiss wire:click="revertFilters" 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">
|
||||
Close
|
||||
</button>
|
||||
<button data-te-modal-dismiss wire:click="applyFilters" type="button"
|
||||
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">
|
||||
Apply
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
class="rounded-lg p-2 text-neutral-500 transition hover:bg-neutral-100 hover:text-black dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form class="max-h-[75vh] overflow-y-auto">
|
||||
<div class="space-y-8 p-6">
|
||||
@foreach (\App\Helpers\FilterCategories::getFilterCategories() as $section => $items)
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-neutral-800 dark:text-white">
|
||||
{{ $section }}
|
||||
</h3>
|
||||
|
||||
<div class="h-px flex-1 bg-neutral-200 ml-4 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@foreach ($items as $tag)
|
||||
@php
|
||||
$slug = Str::slug($tag);
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<input
|
||||
wire:model="blacklist"
|
||||
type="checkbox"
|
||||
id="blacklist-{{ $slug }}"
|
||||
name="blacklist[]"
|
||||
value="{{ $slug }}"
|
||||
class="peer hidden"
|
||||
>
|
||||
|
||||
<label
|
||||
for="blacklist-{{ $slug }}"
|
||||
class="inline-flex cursor-pointer items-center rounded-full border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-700 transition-all duration-200 hover:border-rose-400 hover:bg-rose-50 hover:text-rose-600 peer-checked:border-rose-600 peer-checked:bg-rose-600 peer-checked:text-white dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:border-rose-500 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{{ $tag }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
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
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="applyFilters"
|
||||
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"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,166 +1,102 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalGenres" tabindex="-1" aria-labelledby="modalGenres" aria-modal="true" role="dialog">
|
||||
<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-[60%]">
|
||||
<div
|
||||
class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('Genres')" />
|
||||
<form>
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
@php
|
||||
$taglist = cache()->remember(
|
||||
'searchtags',
|
||||
300,
|
||||
fn() => Conner\Tagging\Model\Tag::where('count', '>', 0)->orderBy('slug', 'ASC')->get(),
|
||||
);
|
||||
$appearances = [
|
||||
'Loli',
|
||||
'Shota',
|
||||
'Milf',
|
||||
'Futanari',
|
||||
'Big Boobs',
|
||||
'Small Boobs',
|
||||
'Dark Skin',
|
||||
'Cosplay',
|
||||
'Elf',
|
||||
'Maid',
|
||||
'Nekomimi',
|
||||
'Nurse',
|
||||
'School Girl',
|
||||
'Succubus',
|
||||
'Teacher',
|
||||
'Trap',
|
||||
'Pregnant',
|
||||
'Glasses',
|
||||
'Swim Suit',
|
||||
'Ugly Bastard',
|
||||
'Monster',
|
||||
];
|
||||
$types = [
|
||||
'3D',
|
||||
'4K',
|
||||
'48Fps',
|
||||
'4K 48Fps',
|
||||
'Censored',
|
||||
'Uncensored',
|
||||
'Comedy',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Vanilla',
|
||||
'Ntr',
|
||||
'Pov',
|
||||
'Filmed',
|
||||
'X-Ray',
|
||||
];
|
||||
$actions = [
|
||||
'Anal',
|
||||
'Bdsm',
|
||||
'Facial',
|
||||
'Blow Job',
|
||||
'Boob Job',
|
||||
'Foot Job',
|
||||
'Hand Job',
|
||||
'Rimjob',
|
||||
'Inflation',
|
||||
'Masturbation',
|
||||
'Public Sex',
|
||||
'Rape',
|
||||
'Reverse Rape',
|
||||
'Threesome',
|
||||
'Orgy',
|
||||
'Gangbang',
|
||||
];
|
||||
@endphp
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalGenres"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalGenresLabel"
|
||||
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-6xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
<div>
|
||||
<h2
|
||||
id="modalGenresLabel"
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Genres & Filters
|
||||
</h2>
|
||||
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($taglist as $tag)
|
||||
@if (in_array($tag->name, $types) || in_array($tag->name, $appearances) || in_array($tag->name, $actions))
|
||||
@continue
|
||||
@endif
|
||||
<li class="inline-block m-1">
|
||||
<input class="m-5 hidden peer" wire:model="tags" type="checkbox"
|
||||
id="tags-{{ $tag->slug }}" name="tags[]" value="{{ $tag->slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="tags-{{ $tag->slug }}">{{ $tag->name }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
<!-- Actions -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Action
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Select tags to filter your content.
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($actions as $tag)
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="tags" type="checkbox"
|
||||
id="tags-{{ $slug }}" name="tags[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="tags-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
<!-- Character Appearance -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Appearance
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($appearances as $tag)
|
||||
@guest
|
||||
@php if ($tag === "Loli" || $tag === "Shota") continue; @endphp
|
||||
@endguest
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="tags" type="checkbox"
|
||||
id="tags-{{ $slug }}" name="tags[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="tags-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
|
||||
<!-- Video Types -->
|
||||
<p class="font-medium leading-normal text-neutral-800 dark:text-white">
|
||||
Type
|
||||
</p>
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
@foreach ($types as $tag)
|
||||
<li class="inline-block m-1">
|
||||
@php $slug = Illuminate\Support\Str::slug($tag); @endphp
|
||||
<input class="m-5 hidden peer" wire:model="tags" type="checkbox"
|
||||
id="tags-{{ $slug }}" name="tags[]" value="{{ $slug }}">
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="tags-{{ $slug }}">{{ $tag }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!--Modal footer-->
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<button data-te-modal-dismiss wire:click="revertFilters" 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">
|
||||
Close
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
class="rounded-lg p-2 text-neutral-500 transition hover:bg-neutral-100 hover:text-black dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form class="max-h-[75vh] overflow-y-auto">
|
||||
<div class="space-y-8 p-6">
|
||||
@foreach (\App\Helpers\FilterCategories::getFilterCategories() as $section => $items)
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-neutral-800 dark:text-white">
|
||||
{{ $section }}
|
||||
</h3>
|
||||
|
||||
<div class="h-px flex-1 bg-neutral-200 ml-4 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@foreach ($items as $tag)
|
||||
@php
|
||||
$slug = Str::slug($tag);
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<input
|
||||
wire:model="tags"
|
||||
type="checkbox"
|
||||
id="tags-{{ $slug }}"
|
||||
name="tags[]"
|
||||
value="{{ $slug }}"
|
||||
class="peer hidden"
|
||||
>
|
||||
|
||||
<label
|
||||
for="tags-{{ $slug }}"
|
||||
class="inline-flex cursor-pointer items-center rounded-full border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-700 transition-all duration-200 hover:border-rose-400 hover:bg-rose-50 hover:text-rose-600 peer-checked:border-rose-600 peer-checked:bg-rose-600 peer-checked:text-white dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:border-rose-500 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{{ $tag }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
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
|
||||
</button>
|
||||
<button data-te-modal-dismiss wire:click="applyFilters" type="button"
|
||||
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">
|
||||
Apply
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="applyFilters"
|
||||
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"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,40 +1,86 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalStudios" tabindex="-1" aria-labelledby="modalStudios" aria-modal="true" role="dialog">
|
||||
<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-[60%]">
|
||||
<div
|
||||
class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('Studios')" />
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalStudios"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalStudios"
|
||||
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-6xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
<div>
|
||||
<h2
|
||||
id="modalGenresLabel"
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Studios
|
||||
</h2>
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
<ul class="list-none text-justify" style="overflow: hidden;">
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Select studios to filter your content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
class="rounded-lg p-2 text-neutral-500 transition hover:bg-neutral-100 hover:text-black dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form class="max-h-[75vh] overflow-y-auto">
|
||||
<div class="space-y-8 p-6">
|
||||
@php $studios = \cache()->remember('searchstudios', 300, fn () => App\Models\Studios::orderBy('name', 'ASC')->get()); @endphp
|
||||
@foreach ($studios as $studio)
|
||||
<li class="inline-block m-1">
|
||||
<input class="m-5 hidden peer" wire:model="studios" type="checkbox"
|
||||
id="studio-{{ $studio->slug }}" name="studios[]" value="{{ $studio->slug }}">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@foreach ($studios as $studio)
|
||||
<div>
|
||||
<input
|
||||
wire:model="studios"
|
||||
type="checkbox"
|
||||
id="studio-{{ $studio->slug }}"
|
||||
name="studios[]"
|
||||
value="{{ $studio->slug }}"
|
||||
class="m-5 hidden peer"
|
||||
>
|
||||
<label
|
||||
class="relative block cursor-pointer p-2 rounded bg-neutral-200 dark:bg-neutral-600 peer-checked:bg-rose-600 text-black peer-checked:text-white dark:peer-checked:text-white dark:text-white select-none"
|
||||
for="studio-{{ $studio->slug }}">{{ $studio->name }}</label>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
for="studio-{{ $studio->slug }}"
|
||||
class="inline-flex cursor-pointer items-center rounded-full border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-700 transition-all duration-200 hover:border-rose-400 hover:bg-rose-50 hover:text-rose-600 peer-checked:border-rose-600 peer-checked:bg-rose-600 peer-checked:text-white dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:border-rose-500 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{{ $studio->name }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--Modal footer-->
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<button data-te-modal-dismiss wire:click="revertFilters" 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">
|
||||
Close
|
||||
</button>
|
||||
<button data-te-modal-dismiss wire:click="applyFilters" type="button"
|
||||
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">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
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
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="applyFilters"
|
||||
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"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,73 @@
|
||||
<!--Verically centered modal-->
|
||||
<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="modalLanguage" tabindex="-1" aria-modal="true" role="dialog">
|
||||
<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-[90%] md:min-[576px]:max-w-[80%] lg:min-[576px]:max-w-[60%] xl:min-[576px]:max-w-[40%] 2xl:min-[576px]:max-w-[20%]">
|
||||
<div
|
||||
class="pointer-events-auto relative flex w-full flex-col rounded-md border-none bg-white bg-clip-padding text-current shadow-lg outline-none dark:bg-neutral-800">
|
||||
<x-modal-header :title="__('Language')" />
|
||||
<div
|
||||
data-te-modal-init
|
||||
id="modalLanguage"
|
||||
tabindex="-1"
|
||||
aria-labelledby="modalLanguage"
|
||||
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-2xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
<div>
|
||||
<h2
|
||||
id="modalGenresLabel"
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Language
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Select your preffered language of the website.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-te-modal-dismiss
|
||||
wire:click="revertFilters"
|
||||
class="rounded-lg p-2 text-neutral-500 transition hover:bg-neutral-100 hover:text-black dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4">
|
||||
<form method="POST" action="{{ route('update.language') }}">
|
||||
@csrf
|
||||
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="language">Select
|
||||
Language:</label>
|
||||
<select name="language" id="language"
|
||||
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="en" @if ('en' == App::getLocale()) selected @endif>
|
||||
English (en)
|
||||
</option>
|
||||
<option value="de" @if ('de' == App::getLocale()) selected @endif>
|
||||
Deutsch (de)
|
||||
</option>
|
||||
<option value="fr" @if ('fr' == App::getLocale()) selected @endif>
|
||||
Français (fr)
|
||||
</option>
|
||||
</select>
|
||||
<div class="pb-6">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="language">Select
|
||||
Language:</label>
|
||||
<select name="language" id="language"
|
||||
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="en" @if ('en' == App::getLocale()) selected @endif>
|
||||
English (en)
|
||||
</option>
|
||||
<option value="de" @if ('de' == App::getLocale()) selected @endif>
|
||||
Deutsch (de)
|
||||
</option>
|
||||
<option value="fr" @if ('fr' == App::getLocale()) selected @endif>
|
||||
Français (fr)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<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">
|
||||
<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">
|
||||
<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
|
||||
</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="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"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user