Compare commits
31 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 |
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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,31 +25,64 @@ 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"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+191
-339
@@ -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 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 }
|
||||
});
|
||||
|
||||
// Player Track Data
|
||||
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.languages = ['en'];
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
|
||||
function initPlayerQualityChange(data) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
player.on('qualitychange', function () {
|
||||
initDash(data);
|
||||
});
|
||||
|
||||
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);
|
||||
function initDash(data) {
|
||||
var videoEl = document.querySelector('video');
|
||||
var quality = player.quality;
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
// Server select (Asia)
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log("Stopped video, because user didn't click play.")
|
||||
}
|
||||
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
player.on('seeked', () => {
|
||||
paintStaticVideo(ctx, video);
|
||||
if (player.currentTime > 0) {
|
||||
lastTime = player.currentTime;
|
||||
}
|
||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
if (player.paused) {
|
||||
paintStaticVideo(ctx, video);
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
function initPlayer() {
|
||||
var videoEl = document.querySelector('#player');
|
||||
var container = videoEl.parentElement;
|
||||
|
||||
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');
|
||||
});
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
|
||||
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
player.on('languagechange', (event) => {
|
||||
let lang = event.detail.plyr.captions.language;
|
||||
|
||||
console.log('Subtitle Event ' + lang);
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
function playerPlayTemp() {
|
||||
clickedPlay = true;
|
||||
if (player) {
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
|
||||
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"));
|
||||
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);
|
||||
});
|
||||
|
||||
// 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];
|
||||
},
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.player = player;
|
||||
|
||||
if (player.captionsActive) {
|
||||
initSubtitles(player.captionLanguage);
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
|
||||
// Periodically save last timestamp
|
||||
if (!isMobile()) {
|
||||
player.initThumbnails(vttThumbsUrl);
|
||||
}
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
initDash(data);
|
||||
}
|
||||
|
||||
initMobileWidescreen(container, videoEl);
|
||||
initMobileDoubleTap(container, videoEl, player);
|
||||
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
player.initHeatmap(episodeId);
|
||||
|
||||
videoEl.addEventListener('play', function onFirstPlay() {
|
||||
videoEl.removeEventListener('play', onFirstPlay);
|
||||
startEngagementTracking(episodeId);
|
||||
});
|
||||
|
||||
setTimeout(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var 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);
|
||||
|
||||
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.setPlayerPreference = function(pref) {
|
||||
localStorage.setItem('hstreamPlayerPreference', pref);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
|
||||
if (playerPreference === 'plyr' && !isIOS()) {
|
||||
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
|
||||
} else {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
if (response.status === 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
streamServers = apiResponse.stream_domains || [];
|
||||
fallbackServers = apiResponse.asia_stream_domains || [];
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = 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;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
streamServerCount = streamServers.length + fallbackServers.length;
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
}
|
||||
else {
|
||||
console.log("Detected Apple Shit. Using different player.")
|
||||
} else {
|
||||
console.log('Detected Apple device. Using Vidstack fallback player.');
|
||||
initVidstackPlayer();
|
||||
}
|
||||
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById("player-alert");
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove("hidden");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
function updateGrid(grid) {
|
||||
// Skip hidden grids
|
||||
if (grid.offsetParent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [...grid.querySelectorAll('.episode-item')];
|
||||
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset visibility first
|
||||
items.forEach(item => {
|
||||
item.style.display = '';
|
||||
});
|
||||
|
||||
// Determine actual column count
|
||||
const firstTop = items[0].offsetTop;
|
||||
|
||||
let columns = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.offsetTop !== firstTop) {
|
||||
break;
|
||||
}
|
||||
|
||||
columns++;
|
||||
}
|
||||
|
||||
const rows = parseInt(grid.dataset.rows || '2', 10);
|
||||
|
||||
const visibleItems = columns * rows;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
item.style.display = index < visibleItems
|
||||
? ''
|
||||
: 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllEpisodeGrids() {
|
||||
document.querySelectorAll('.episode-grid').forEach(updateGrid);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
updateGrid(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.episode-grid').forEach(grid => {
|
||||
observer.observe(grid);
|
||||
});
|
||||
|
||||
let resizeTimeout;
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
|
||||
resizeTimeout = setTimeout(() => {
|
||||
updateAllEpisodeGrids();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
window.addEventListener('load', updateAllEpisodeGrids);
|
||||
+274
-34
@@ -1,73 +1,313 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
// Theming
|
||||
if (localStorage.theme !== 'light') {
|
||||
Chart.defaults.color = "#ADBABD";
|
||||
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
|
||||
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
|
||||
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
|
||||
/**
|
||||
* Theme-aware chart defaults
|
||||
*/
|
||||
function getChartColors() {
|
||||
const isDark = localStorage.theme !== 'light' &&
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
textColor: '#ADBABD',
|
||||
gridColor: 'rgba(255, 255, 255, 0.06)',
|
||||
fillStart: 'rgba(190, 18, 60, 0.2)',
|
||||
fillEnd: 'rgba(190, 18, 60, 0.0)',
|
||||
borderColor: 'rgba(190, 18, 60, 0.9)',
|
||||
pointColor: 'rgba(190, 18, 60, 1)',
|
||||
pointHoverColor: '#ffffff',
|
||||
skeletonBase: '#262626',
|
||||
skeletonShimmer: '#333333',
|
||||
};
|
||||
}
|
||||
|
||||
// Get Tags from API
|
||||
window.axios.get('/v1/monthly-views').then(function (response) {
|
||||
if (response.status != 200) {
|
||||
return;
|
||||
return {
|
||||
textColor: '#6B7280',
|
||||
gridColor: 'rgba(0, 0, 0, 0.06)',
|
||||
fillStart: 'rgba(190, 18, 60, 0.15)',
|
||||
fillEnd: 'rgba(190, 18, 60, 0.0)',
|
||||
borderColor: 'rgba(190, 18, 60, 1.0)',
|
||||
pointColor: 'rgba(190, 18, 60, 1)',
|
||||
pointHoverColor: '#ffffff',
|
||||
skeletonBase: '#E5E7EB',
|
||||
skeletonShimmer: '#F3F4F6',
|
||||
};
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels: response.data.map((entry) => { return entry.date }),
|
||||
/**
|
||||
* Show the skeleton loader
|
||||
*/
|
||||
function showSkeleton() {
|
||||
const skeleton = document.getElementById('chart-skeleton');
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
const error = document.getElementById('chart-error');
|
||||
|
||||
if (skeleton) skeleton.style.display = '';
|
||||
if (canvas) canvas.style.opacity = '0';
|
||||
if (error) error.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the skeleton and show the chart canvas
|
||||
*/
|
||||
function hideSkeleton() {
|
||||
const skeleton = document.getElementById('chart-skeleton');
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
|
||||
if (skeleton) {
|
||||
// Fade out skeleton
|
||||
skeleton.style.transition = 'opacity 0.4s ease-out';
|
||||
skeleton.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (skeleton) skeleton.style.display = 'none';
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (canvas) {
|
||||
setTimeout(() => {
|
||||
canvas.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error state
|
||||
*/
|
||||
function showError() {
|
||||
const skeleton = document.getElementById('chart-skeleton');
|
||||
const error = document.getElementById('chart-error');
|
||||
|
||||
if (skeleton) skeleton.style.display = 'none';
|
||||
if (error) {
|
||||
error.classList.remove('hidden');
|
||||
error.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide error state
|
||||
*/
|
||||
function hideError() {
|
||||
const error = document.getElementById('chart-error');
|
||||
if (error) {
|
||||
error.classList.add('hidden');
|
||||
error.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gradient fill for the chart
|
||||
*/
|
||||
function createGradient(ctx, colors) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight);
|
||||
gradient.addColorStop(0, colors.fillStart);
|
||||
gradient.addColorStop(1, colors.fillEnd);
|
||||
return gradient;
|
||||
}
|
||||
|
||||
let monthlyViewChart = null;
|
||||
|
||||
/**
|
||||
* Render the chart with data
|
||||
*/
|
||||
function renderChart(data) {
|
||||
const colors = getChartColors();
|
||||
const canvas = document.getElementById('monthlyChart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Destroy previous chart instance if it exists
|
||||
if (monthlyViewChart) {
|
||||
monthlyViewChart.destroy();
|
||||
monthlyViewChart = null;
|
||||
}
|
||||
|
||||
const gradient = createGradient(ctx, colors);
|
||||
|
||||
const chartData = {
|
||||
labels: data.map((entry) => entry.date),
|
||||
datasets: [{
|
||||
label: 'Views',
|
||||
fill: false,
|
||||
backgroundColor: 'rgba(190, 18, 60, 0.3)',
|
||||
borderColor: 'rgba(190, 18, 60, 1.0)',
|
||||
fill: true,
|
||||
backgroundColor: gradient,
|
||||
borderColor: colors.borderColor,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: colors.pointColor,
|
||||
pointBorderColor: colors.pointColor,
|
||||
pointHoverBackgroundColor: colors.pointHoverColor,
|
||||
pointHoverBorderColor: colors.borderColor,
|
||||
pointHoverBorderWidth: 2,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 2.5,
|
||||
pointHitRadius: 20,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
data: response.data.map((entry) => { return entry.count }),
|
||||
tension: 0.4,
|
||||
data: data.map((entry) => entry.count),
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: data,
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 1200,
|
||||
easing: 'easeOutQuart',
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Views the last 28 days',
|
||||
font: {
|
||||
size: 18
|
||||
display: false,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 17, 17, 0.95)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#D1D5DB',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 10,
|
||||
displayColors: false,
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
},
|
||||
titleFont: {
|
||||
size: 12,
|
||||
weight: '600',
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return 'Views: ' + new Intl.NumberFormat().format(context.parsed.y);
|
||||
},
|
||||
title: function(context) {
|
||||
return context[0].label;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: colors.gridColor,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.textColor,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
maxTicksLimit: 14,
|
||||
maxRotation: 0,
|
||||
},
|
||||
title: {
|
||||
display: true
|
||||
display: false,
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Views'
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: colors.gridColor,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.textColor,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(1) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
|
||||
return value;
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 40000
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const monthlyViewChart = new Chart(
|
||||
document.getElementById('monthlyChart'),
|
||||
config
|
||||
);
|
||||
}).catch(function (error) {
|
||||
console.log(error);
|
||||
hideError();
|
||||
monthlyViewChart = new Chart(canvas, config);
|
||||
hideSkeleton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch chart data from the API
|
||||
*/
|
||||
async function fetchChartData() {
|
||||
showSkeleton();
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/v1/monthly-views');
|
||||
|
||||
if (response.status !== 200 || !response.data || response.data.length === 0) {
|
||||
throw new Error('Invalid or empty response');
|
||||
}
|
||||
|
||||
renderChart(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chart data:', error);
|
||||
showError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry loading the chart (called from error state button)
|
||||
*/
|
||||
window.retryChart = function() {
|
||||
if (monthlyViewChart) {
|
||||
monthlyViewChart.destroy();
|
||||
monthlyViewChart = null;
|
||||
}
|
||||
fetchChartData();
|
||||
};
|
||||
|
||||
// Listen for theme changes to re-render chart
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
if (monthlyViewChart) {
|
||||
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||
date: monthlyViewChart.data.labels[index],
|
||||
count: value,
|
||||
}));
|
||||
renderChart(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Observe theme class changes on html element
|
||||
const htmlElement = document.documentElement;
|
||||
if (htmlElement) {
|
||||
themeObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
// Start the fetch when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Small delay to ensure the page has rendered and skeleton is visible
|
||||
setTimeout(() => {
|
||||
fetchChartData();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Handle window resize for theme changes (system preference)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (!('theme' in localStorage) && monthlyViewChart) {
|
||||
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||
date: monthlyViewChart.data.labels[index],
|
||||
count: value,
|
||||
}));
|
||||
renderChart(data);
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,32 @@
|
||||
<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="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">
|
||||
@@ -16,6 +35,7 @@
|
||||
</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,23 +0,0 @@
|
||||
@auth
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||
<div class="relative p-5 bg-white dark:bg-neutral-700/40 rounded-lg overflow-hidden z-10">
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<div class="float-left">
|
||||
<a data-te-toggle="modal" data-te-target="#modalUploadEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
||||
<i class="fa-solid fa-plus pr-[6px]"></i> Add Episode
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<div class="float-right">
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<a data-te-toggle="modal" data-te-target="#modalAddSubtitles" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
||||
<i class="fa-solid fa-plus pr-[6px]"></i> Add Subtitles
|
||||
</a>
|
||||
@endif
|
||||
<a data-te-toggle="modal" data-te-target="#modalEditEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
||||
<i class="fa-solid fa-pen pr-[6px]"></i> Edit Episode
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
@@ -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">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="group w-full p-1">
|
||||
<a
|
||||
href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
|
||||
class="block overflow-hidden rounded-2xl border dark:border-neutral-800 border-neutral-300 dark:bg-neutral-900 transition-all duration-300 hover:-translate-y-1 dark:hover:border-neutral-700 hover:border-neutral-400 hover:shadow-2xl hover:shadow-black/30"
|
||||
class="block overflow-hidden rounded-2xl border border-neutral-200 bg-white transition-all duration-300 hover:-translate-y-1 hover:border-neutral-400 hover:shadow-xl dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700"
|
||||
>
|
||||
<div class="relative overflow-hidden">
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
width="400"
|
||||
class="aspect-[11/16] w-full object-cover object-center transform-gpu transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
class="aspect-[11/16] w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
>
|
||||
@elseif ($view === 'thumbnail')
|
||||
@php
|
||||
@@ -50,49 +50,56 @@
|
||||
loading="lazy"
|
||||
width="1000"
|
||||
data-gallery='@json($galleryImages)'
|
||||
class="preview-gallery aspect-video w-full object-cover object-center transform-gpu transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
class="preview-gallery aspect-video w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
>
|
||||
@endif
|
||||
|
||||
{{-- Overlay Gradient --}}
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent"></div>
|
||||
{{-- Dark Overlay --}}
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent"></div>
|
||||
|
||||
{{-- Top Row --}}
|
||||
<div class="absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3">
|
||||
{{-- 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-xl border border-red-500/30 bg-red-900/70 px-2.5 py-1 text-xs font-semibold text-white">
|
||||
<div class="rounded-full bg-red-700/40 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-white ring-1 ring-red-700/70">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||
{{ $problematic }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Resolution --}}
|
||||
<div class="ml-auto rounded-xl bg-black/70 px-2.5 py-1 text-xs font-semibold tracking-wide text-neutral-100 ring-1 ring-white/10">
|
||||
<div class="ml-auto rounded-full bg-black/70 px-2 py-1 text-[11px] font-semibold tracking-wide text-white ring-1 ring-white/10">
|
||||
{{ $episode->getResolution() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bottom Stats --}}
|
||||
<div class="absolute inset-x-0 bottom-0 z-20 p-3">
|
||||
<div class="flex items-end justify-between gap-3">
|
||||
{{-- 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-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm font-bold text-neutral-200">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm font-bold text-neutral-200">
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-regular fa-eye text-neutral-400"></i>
|
||||
<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-400"></i>
|
||||
<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-400"></i>
|
||||
<i class="fa-regular fa-comment text-neutral-200 font-bold"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -102,9 +109,9 @@
|
||||
@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-check mr-1"></i> Watched
|
||||
<i class="fa-solid fa-eye mr-1"></i> Watched
|
||||
@else
|
||||
<i class="fa-solid fa-check"></i>
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@@ -116,13 +123,7 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="relative isolate border-t dark:border-neutral-800 dark:bg-neutral-900 bg-white border-neutral-100 p-4">
|
||||
<h3 class="text-sm font-semibold leading-relaxed dark:text-neutral-100 text-neutral-900 transition-colors duration-200 dark:group-hover:text-white">
|
||||
{{ $title }}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
</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>
|
||||
@@ -28,6 +28,4 @@
|
||||
<div class="mx-auto pt-6 sm:px-6 lg:px-8 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%] pb-2">
|
||||
@include('home.partials.comments')
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/responsive.js'])
|
||||
</x-app-layout>
|
||||
@@ -55,10 +55,9 @@
|
||||
>
|
||||
|
||||
{{-- Resolution Badge --}}
|
||||
<span
|
||||
class="absolute right-0 top-0 rounded-bl-lg bg-rose-700/70 px-3 py-1 text-xs font-semibold text-white shadow-lg backdrop-blur">
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Gradient Overlay --}}
|
||||
<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,20 +1,34 @@
|
||||
@props(['isThumbnail'])
|
||||
|
||||
@php
|
||||
$isThumbnail = $showThumbnails;
|
||||
|
||||
$gridClasses = $isThumbnail
|
||||
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6'
|
||||
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
|
||||
|
||||
// Render enough items for largest possible layout
|
||||
$limit = 24;
|
||||
$limit = 16;
|
||||
|
||||
$view = $isThumbnail ? 'thumbnail' : 'poster';
|
||||
@endphp
|
||||
|
||||
@if ($isThumbnail)
|
||||
<div
|
||||
class="episode-grid grid {{ $gridClasses }}"
|
||||
data-rows="2"
|
||||
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 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)
|
||||
@@ -22,7 +36,7 @@
|
||||
: $ep;
|
||||
@endphp
|
||||
|
||||
<div class="episode-item">
|
||||
<div class="episode-item p-1">
|
||||
<x-episode-cover
|
||||
:episode="$episode"
|
||||
:view="$view"
|
||||
|
||||
@@ -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 ">
|
||||
|
||||
@@ -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,78 +1,365 @@
|
||||
<x-app-layout>
|
||||
<div class="container mx-auto px-4 py-12 md:py-24">
|
||||
<section class="text-center mb-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
|
||||
{{-- Header Section --}}
|
||||
<section class="text-center mb-10 md:mb-14">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img
|
||||
src="/images/cropped-HS-1-270x270.webp"
|
||||
alt="hstream.moe Logo"
|
||||
class="max-w-[150px] w-full h-auto rounded-lg"
|
||||
class="max-w-[120px] w-full h-auto rounded-xl shadow-lg hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
<!-- View Count Card -->
|
||||
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div class="flex justify-center mb-4">
|
||||
<i class="fa-solid fa-eye text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ number_format($viewCount) }}
|
||||
</div>
|
||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
||||
total views
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<!-- Episode Count Card -->
|
||||
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div class="flex justify-center mb-4">
|
||||
<i class="fa-solid fa-video text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ $episodeCount }}
|
||||
</div>
|
||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
||||
episodes on this site
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<!-- Hentai Count Card -->
|
||||
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div class="flex justify-center mb-4">
|
||||
<i class="fa-solid fa-list text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ $hentaiCount }}
|
||||
</div>
|
||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
||||
hentais on this site
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<!-- Watch Time Card -->
|
||||
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div class="flex justify-center mb-4">
|
||||
<i class="fa-solid fa-clock text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ number_format($viewCount * 6) }}
|
||||
</div>
|
||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
||||
estimated minutes of watch time
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Container -->
|
||||
<div class="mt-12 mx-auto max-w-4xl">
|
||||
<div class="bg-gray-50 dark:bg-neutral-950 rounded-xl p-4 md:p-6 shadow-inner hidden sm:block">
|
||||
<canvas id="monthlyChart" class="w-full h-64 md:h-80"></canvas>
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
|
||||
Site Statistics
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
|
||||
A comprehensive overview of hstream.moe's content and community activity
|
||||
</p>
|
||||
<div class="mt-5 flex justify-center">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live data · Updated hourly
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Primary Stats Grid --}}
|
||||
<section class="mb-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Total Views --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-sky-400/10 dark:bg-sky-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-eye text-sky-600 dark:text-sky-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $viewCount }}">
|
||||
{{ number_format($viewCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Views</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Episodes --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-violet-400/10 dark:bg-violet-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-video text-violet-600 dark:text-violet-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $episodeCount }}">
|
||||
{{ number_format($episodeCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Series --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-rose-400/10 dark:bg-rose-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-rose-100 dark:bg-rose-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-list text-rose-600 dark:text-rose-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $hentaiCount }}">
|
||||
{{ number_format($hentaiCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Series</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Users --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-400/10 dark:bg-emerald-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-users text-emerald-600 dark:text-emerald-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $userCount }}">
|
||||
{{ number_format($userCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Registered Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Secondary Stats Grid --}}
|
||||
<section class="mb-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Total Likes --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-heart text-red-500 dark:text-red-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $likeCount }}">
|
||||
{{ number_format($likeCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Likes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Comments --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-comments text-amber-600 dark:text-amber-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $commentCount }}">
|
||||
{{ number_format($commentCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Comments</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Total Downloads --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-download text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $downloadCount }}">
|
||||
{{ number_format($downloadCount) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Avg Views Per Episode --}}
|
||||
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-chart-simple text-orange-600 dark:text-orange-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $avgViewsPerEpisode }}">
|
||||
{{ number_format($avgViewsPerEpisode) }}
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Avg Views / Episode</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}}
|
||||
<section class="mb-10">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||
{{-- Today's Views --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-sky-50 to-sky-100/50 dark:from-sky-950/40 dark:to-sky-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-sky-200/50 dark:border-sky-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-sky-600 dark:text-sky-400 font-semibold uppercase tracking-wider mb-2">Today</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $todayViews }}">
|
||||
{{ number_format($todayViews) }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">views so far</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- This Week's Views with Trend --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-violet-50 to-violet-100/50 dark:from-violet-950/40 dark:to-violet-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-violet-200/50 dark:border-violet-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-violet-600 dark:text-violet-400 font-semibold uppercase tracking-wider mb-2">Views This Week</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $weeklyViews }}">
|
||||
{{ number_format($weeklyViews) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@php
|
||||
$trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0;
|
||||
@endphp
|
||||
@if($trend > 0)
|
||||
<i class="fa-solid fa-arrow-trend-up text-green-500 text-xs"></i>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 font-semibold">{{ $trend }}% vs last week</span>
|
||||
@elseif($trend < 0)
|
||||
<i class="fa-solid fa-arrow-trend-down text-red-500 text-xs"></i>
|
||||
<span class="text-xs text-red-600 dark:text-red-400 font-semibold">{{ abs($trend) }}% vs last week</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-500 dark:text-neutral-400 font-medium">same as last week</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- New Episodes This Week --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-950/40 dark:to-emerald-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-emerald-200/50 dark:border-emerald-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider mb-2">New This Week</p>
|
||||
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $newEpisodesThisWeek }}">
|
||||
{{ number_format($newEpisodesThisWeek) }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">episodes added</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 4K / 48fps Content --}}
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-rose-50 to-rose-100/50 dark:from-rose-950/40 dark:to-rose-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-rose-200/50 dark:border-rose-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wider mb-2">High Quality</p>
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodes4k }}">{{ number_format($episodes4k) }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-neutral-400">4K</span>
|
||||
<span class="text-gray-300 dark:text-neutral-700">/</span>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodesUHD48 }}">{{ number_format($episodesUHD48) }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-neutral-400">48fps</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">4K & 48fps content</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Views Chart Section --}}
|
||||
<section class="mb-10">
|
||||
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white">Views Over Time</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-neutral-400">Daily views for the past 28 days</p>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Chart wrapper with skeleton loader --}}
|
||||
<div class="relative min-h-[300px] md:min-h-[350px]">
|
||||
{{-- Skeleton Loader --}}
|
||||
<div id="chart-skeleton" class="absolute inset-0 flex flex-col justify-end px-1 pb-6 z-10">
|
||||
<div class="flex items-end justify-between gap-1 md:gap-2 h-full">
|
||||
@for ($i = 0; $i < 28; $i++)
|
||||
@php
|
||||
$heights = [55, 40, 65, 35, 60, 45, 70, 50, 75, 38, 58, 42, 80, 48, 68, 33, 62, 44, 72, 52, 55, 40, 65, 58, 78, 46, 63, 50];
|
||||
$h = $heights[$i % count($heights)];
|
||||
@endphp
|
||||
<div class="flex-1 flex flex-col items-center justify-end gap-1">
|
||||
<div class="w-full rounded-md bg-gray-200 dark:bg-neutral-800 animate-pulse" style="height: {{ $h }}%; min-height: 8px;"></div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
{{-- Shimmer overlay --}}
|
||||
<div class="absolute inset-0 shimmer-overlay rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
{{-- Actual Chart Canvas --}}
|
||||
<canvas id="monthlyChart" class="w-full h-[300px] md:h-[350px] relative z-0 opacity-0 transition-opacity duration-500"></canvas>
|
||||
|
||||
{{-- Error State --}}
|
||||
<div id="chart-error" class="absolute inset-0 hidden flex-col items-center justify-center bg-white/90 dark:bg-neutral-900/90 rounded-lg z-20">
|
||||
<div class="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-3">
|
||||
<i class="fa-solid fa-triangle-exclamation text-red-500 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-neutral-300 font-medium">Unable to load chart data</p>
|
||||
<button onclick="retryChart()" class="mt-3 px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-700 rounded-lg transition-colors">
|
||||
<i class="fa-solid fa-rotate mr-1"></i> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile legend --}}
|
||||
<div class="flex sm:hidden items-center justify-center gap-3 mt-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Bottom Section: Top Tags --}}
|
||||
@if($topTags->isNotEmpty())
|
||||
<section class="mb-8">
|
||||
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Most Popular Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($topTags as $tag)
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold
|
||||
bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-neutral-300
|
||||
hover:bg-rose-100 dark:hover:bg-rose-900/40 hover:text-rose-700 dark:hover:text-rose-400
|
||||
transition-colors duration-200 cursor-default">
|
||||
#{{ $tag->name }}
|
||||
<span class="text-[10px] text-gray-400 dark:text-neutral-500">{{ number_format($tag->count) }}</span>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Footer Note --}}
|
||||
<div class="text-center text-xs text-gray-400 dark:text-neutral-600">
|
||||
Statistics are cached and update periodically. Data shown may not reflect real-time changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Animated Counter Script --}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const animateCounters = () => {
|
||||
document.querySelectorAll('.stat-counter').forEach(counter => {
|
||||
const target = parseInt(counter.dataset.target, 10);
|
||||
if (isNaN(target)) return;
|
||||
|
||||
const duration = 1500;
|
||||
const start = performance.now();
|
||||
const startVal = 0;
|
||||
|
||||
const step = (currentTime) => {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.round(startVal + (target - startVal) * eased);
|
||||
|
||||
const formatted = new Intl.NumberFormat().format(current);
|
||||
counter.textContent = formatted;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
};
|
||||
|
||||
// Use IntersectionObserver to trigger counters when visible
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounters();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
const firstCounter = document.querySelector('.stat-counter');
|
||||
if (firstCounter) {
|
||||
observer.observe(firstCounter);
|
||||
} else {
|
||||
// Fallback
|
||||
animateCounters();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@vite(['resources/js/stats.js'])
|
||||
</x-app-layout>
|
||||
@@ -1,60 +1,291 @@
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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-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
|
||||
<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..."
|
||||
>
|
||||
@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">
|
||||
</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">
|
||||
<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 }}
|
||||
</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">
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,105 +1,435 @@
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
ID
|
||||
<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">
|
||||
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
|
||||
@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">
|
||||
<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 }}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,110 +1,189 @@
|
||||
<div>
|
||||
<div class="flex" id="comment-{{ $comment->id }}">
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<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" src="{{ asset('images/default-avatar.webp') }}" alt="Deleted comment">
|
||||
<img
|
||||
class="h-10 w-10 rounded-full object-cover opacity-60"
|
||||
src="{{ asset('images/default-avatar.webp') }}"
|
||||
alt="Deleted comment"
|
||||
>
|
||||
@else
|
||||
<img class="h-10 w-10 rounded-full" src="{{ $comment->user->getAvatar() }}" alt="{{ $comment->user->name }}">
|
||||
<img
|
||||
class="h-10 w-10 rounded-full object-cover ring-2 ring-white dark:ring-neutral-800"
|
||||
src="{{ $comment->user->getAvatar() }}"
|
||||
alt="{{ $comment->user->name }}"
|
||||
>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex gap-2">
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
<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)))
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Deleted ({{ $comment->user->name }})</p>
|
||||
Deleted ({{ $comment->user->name }})
|
||||
@else
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Deleted</p>
|
||||
Deleted
|
||||
@endif
|
||||
</span>
|
||||
|
||||
@else
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ $comment->user->name }}</p>
|
||||
|
||||
<span class="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ $comment->user->name }}
|
||||
</span>
|
||||
|
||||
@endif
|
||||
|
||||
{{-- Badges --}}
|
||||
@if($comment->user->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<a data-te-toggle="tooltip" title="Admin"><i class="fa-solid fa-crown text-yellow-600"></i></a>
|
||||
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400">
|
||||
<i class="fa-solid fa-crown mr-1"></i>
|
||||
Admin
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($comment->user->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||
<a data-te-toggle="tooltip" title="Admin" class="text-rose-600">Moderator</a>
|
||||
<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))
|
||||
<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>
|
||||
<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>
|
||||
<div class="mt-1 flex-grow w-full">
|
||||
|
||||
{{-- Body --}}
|
||||
<div class="prose prose-sm max-w-none dark:prose-invert">
|
||||
|
||||
@if($comment->isDeletedByModerator())
|
||||
<div class="text-gray-700 dark:text-gray-200">Deleted by moderation.</div>
|
||||
|
||||
<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="text-gray-700 dark:text-gray-300 pt-1">Original comment: {!! $comment->presenter()->markdownBody() !!}</div>
|
||||
@endif
|
||||
@else
|
||||
@if ($isEditing)
|
||||
<form wire:submit.prevent="editComment">
|
||||
<div>
|
||||
<label for="comment" class="sr-only">Comment body</label>
|
||||
<textarea id="comment" name="comment" rows="3"
|
||||
class="bg-white dark:bg-neutral-700 shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 dark:border-gray-400/40 text-gray-900 dark:text-gray-200 placeholder:text-gray-400 rounded-md
|
||||
@error('editState.body') border-red-500 @enderror"
|
||||
placeholder="Write something" wire:model.defer="editState.body"></textarea>
|
||||
@error('editState.body')
|
||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="mt-3 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-800">
|
||||
{!! $comment->presenter()->markdownBody() !!}
|
||||
</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
|
||||
@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 class="text-gray-700 dark:text-gray-200">
|
||||
{!! $comment->presenter()->markdownBody() !!}
|
||||
</div>
|
||||
<div class="mt-2 space-x-2 flex flex-row">
|
||||
<span class="text-gray-500 dark:text-gray-300">
|
||||
@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 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 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
|
||||
<!-- Like Button -->
|
||||
<button class="text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap" wire:click="like">
|
||||
<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"
|
||||
>
|
||||
@if ($liked)
|
||||
<i class="fa-solid fa-heart text-rose-600"></i> {{ $likeCount }}
|
||||
<i class="fa-solid fa-heart text-rose-600"></i>
|
||||
@else
|
||||
<i class="fa-solid fa-heart"></i> {{ $likeCount }}
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
@endif
|
||||
|
||||
{{ $likeCount }}
|
||||
</button>
|
||||
@endauth
|
||||
|
||||
{{-- Actions --}}
|
||||
@auth
|
||||
|
||||
@if ($comment->depth() < 2)
|
||||
<button wire:click="$toggle('isReplying')" type="button" class="text-gray-900 dark:text-gray-100 font-medium">
|
||||
<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')" type="button" class="text-gray-900 dark:text-gray-100 font-medium">
|
||||
<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="{
|
||||
<button
|
||||
x-data="{
|
||||
confirmCommentDeletion () {
|
||||
if (window.confirm('Are you sure you want to delete this comment?')) {
|
||||
if (window.confirm('Delete this comment?')) {
|
||||
@this.call('deleteComment');
|
||||
}
|
||||
}
|
||||
}"
|
||||
@click="confirmCommentDeletion"
|
||||
type="button"
|
||||
class="text-gray-900 dark:text-gray-100 font-medium"
|
||||
class="font-medium text-red-500 transition hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -113,41 +192,62 @@
|
||||
@can ('restore', $comment)
|
||||
<button
|
||||
wire:click="restoreComment"
|
||||
type="button"
|
||||
class="text-gray-900 dark:text-gray-100 font-medium"
|
||||
class="font-medium text-emerald-600 transition hover:text-emerald-700"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
@endcan
|
||||
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ml-14 mt-6">
|
||||
@if ($isReplying)
|
||||
<form wire:submit.prevent="postReply" class="my-4">
|
||||
<div>
|
||||
<label for="comment" class="sr-only">Reply body</label>
|
||||
<textarea id="comment" name="comment" rows="3"
|
||||
class="bg-white dark:bg-neutral-700 shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 dark:border-gray-400/40 text-gray-900 dark:text-gray-200 placeholder:text-gray-400 rounded-md
|
||||
@error('replyState.body') border-red-500 @enderror"
|
||||
placeholder="Write something" wire:model.defer="replyState.body"></textarea>
|
||||
@error('replyState.body')
|
||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500">
|
||||
Comment
|
||||
|
||||
{{-- 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>
|
||||
@@ -1,57 +1,93 @@
|
||||
<section>
|
||||
<div class="bg-white dark:bg-neutral-800 shadow sm:rounded-xl sm:overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h2 class="leading-normal font-bold text-lg text-gray-900 dark:text-gray-200">Comments</h2>
|
||||
<div id="comments" class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="border-b border-neutral-200 px-6 py-5 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">
|
||||
Comments
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Comment Input -->
|
||||
<div class="bg-gray-50 dark:bg-neutral-800 px-4 py-6 sm:px-6 border-t border-b dark:border-neutral-950 border-neutral-200">
|
||||
|
||||
{{-- 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">
|
||||
<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 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" 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>
|
||||
<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>
|
||||
<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
|
||||
|
||||
<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
|
||||
<p class="text-gray-900 dark:text-gray-200">Log in to comment.</p>
|
||||
<div class="rounded-xl border border-dashed border-neutral-300 p-6 text-center dark:border-neutral-700">
|
||||
<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-4 py-6 sm:px-6">
|
||||
<div class="space-y-8">
|
||||
{{-- 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
|
||||
<p class="text-gray-900 dark:text-gray-200">No comments yet.</p>
|
||||
|
||||
<div class="rounded-2xl border border-dashed border-neutral-300 py-12 text-center dark:border-neutral-700">
|
||||
<p class="text-neutral-500 dark:text-neutral-400">
|
||||
No comments yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -8,10 +8,18 @@
|
||||
</div>
|
||||
<div class="flex flex-col text-center w-full">
|
||||
@if($fillNumbers)
|
||||
@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
|
||||
@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>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
class="max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-lg transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
|
||||
<div class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-neutral-800">
|
||||
class="w-full max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-xl transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-neutral-800">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 font-medium">
|
||||
@if($episodes->count())
|
||||
{{ __('Search result for ') }} “{{ $query ?: $navSearch }}”
|
||||
{{ __('Search result for ') }}<span class="text-rose-600 dark:text-rose-400 font-semibold">"{{ $query ?: $navSearch }}"</span>
|
||||
@else
|
||||
{{ __('No results') }}
|
||||
@endif
|
||||
@@ -60,46 +61,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- content area: responsive grid --}}
|
||||
<div class="p-4">
|
||||
{{-- Results List --}}
|
||||
<div>
|
||||
@if($episodes->count())
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
<ul class="divide-y divide-gray-100 dark:divide-neutral-800" role="listbox">
|
||||
@foreach($episodes as $episode)
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}" class="group block rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-950 border border-transparent hover:border-gray-200 dark:hover:border-neutral-700 shadow-sm hover:shadow-md transition">
|
||||
<div class="relative aspect-video">
|
||||
<li role="option" aria-selected="false">
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}"
|
||||
class="flex items-center gap-4 px-5 py-2 group/row hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
|
||||
>
|
||||
{{-- Left: Cover Image --}}
|
||||
<div class="flex-shrink-0 relative">
|
||||
<div class="w-16 h-[6rem] rounded-lg overflow-hidden ring-1 ring-gray-200/80 dark:ring-neutral-700/80 shadow-sm group-hover/row:ring-rose-300 dark:group-hover/row:ring-rose-700 group-hover/row:shadow-md transition-all duration-200">
|
||||
<img
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||
loading="lazy"
|
||||
class="object-cover w-full h-full"
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"
|
||||
class="object-cover w-full h-full group-hover/row:scale-105 transition-transform duration-300"
|
||||
src="{{ $episode->cover_url }}"
|
||||
>
|
||||
<span class="absolute right-0 top-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs font-semibold rounded-tr rounded-bl-xl px-2 py-1">{{ $episode->getResolution() }}</span>
|
||||
<div class="absolute left-0 bottom-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs rounded-tr-xl px-2 py-1 font-medium">
|
||||
<i class="fa-regular fa-eye mr-1"></i> {{ $episode->viewCountFormatted() }}
|
||||
<i class="fa-regular fa-heart ml-2"></i> {{ $episode->likeCount() }}
|
||||
</div>
|
||||
<span class="absolute -top-1.5 -right-1.5 bg-rose-600 text-white text-[0.6rem] font-bold leading-none px-1.5 py-0.5 rounded-md shadow-sm">
|
||||
E{{ $episode->episode }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Center: Title, Publisher, Tags --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
{{-- Row 1: Title --}}
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate group-hover/row:text-rose-700 dark:group-hover/row:text-rose-400 transition-colors duration-150">
|
||||
{{ $episode->title }}
|
||||
</h3>
|
||||
|
||||
{{-- Row 2: Publisher --}}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
|
||||
<i class="fa-regular fa-building mr-1 text-[0.65rem] opacity-70"></i>
|
||||
{{ $episode->studio?->name ?? __('Unknown') }}
|
||||
</p>
|
||||
|
||||
{{-- Row 3: Tags --}}
|
||||
<div class="mt-1.5 flex items-center gap-1 flex-wrap">
|
||||
@php
|
||||
$tags = $episode->tagNames();
|
||||
$visibleTags = array_slice($tags, 0, 3);
|
||||
$remainingCount = count($tags) - 3;
|
||||
@endphp
|
||||
@foreach($visibleTags as $tag)
|
||||
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-rose-100/80 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300 ring-1 ring-inset ring-rose-200/60 dark:ring-rose-800/60">
|
||||
{{ $tag }}
|
||||
</span>
|
||||
@endforeach
|
||||
@if($remainingCount > 0)
|
||||
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-gray-100 text-gray-500 dark:bg-neutral-800 dark:text-gray-400">
|
||||
+{{ $remainingCount }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h3 class="text-sm font-semibold truncate text-gray-900 dark:text-white">{{ $episode->title }} - {{ $episode->episode }}</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> {{ \Illuminate\Support\Str::limit($episode->description ?? '', 80) }}</p>
|
||||
|
||||
{{-- Right: View Count, Like Count --}}
|
||||
<div class="flex-shrink-0 flex flex-col items-end gap-1.5">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Views') }}">
|
||||
<i class="fa-regular fa-eye text-[0.65rem] opacity-70"></i>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $episode->viewCountFormatted() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Likes') }}">
|
||||
<i class="fa-regular fa-heart text-[0.65rem] opacity-70 text-rose-500 dark:text-rose-400"></i>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ number_format($episode->likeCount()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
{{-- Advanced Search card --}}
|
||||
<a href="{{ route('hentai.search', ['search' => $query]) }}" class="flex items-center justify-center rounded-xl border border-dashed border-gray-200 dark:border-neutral-700 p-6 hover:bg-gray-50 dark:hover:bg-neutral-900 transition">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-rose-600 mb-1">🔎</div>
|
||||
<div class="font-semibold text-sm dark:text-white">Advanced Search</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">View more results</div>
|
||||
{{-- Advanced Search footer --}}
|
||||
<li>
|
||||
<a href="{{ route('hentai.search', ['search' => $query]) }}"
|
||||
class="block px-5 py-3.5 text-center group/advanced hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 group-hover/advanced:text-rose-700 dark:group-hover/advanced:text-rose-400 transition-colors">
|
||||
{{ __('Advanced Search') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('View all results') }}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover/advanced:text-rose-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@else
|
||||
{{-- Empty state --}}
|
||||
<div class="py-12 text-center text-sm text-gray-600 dark:text-gray-300">
|
||||
<div class="mb-3">No results found for “{{ $query ?: $navSearch }}”</div>
|
||||
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-block px-4 py-2 rounded-lg bg-rose-700 text-white text-sm hover:bg-rose-800">Try advanced search</a>
|
||||
<div class="mb-3">{{ __('No results found for') }} <span class="font-semibold text-rose-600">"{{ $query ?: $navSearch }}"</span></div>
|
||||
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-rose-700 text-white text-sm font-medium hover:bg-rose-800 transition shadow-sm hover:shadow-md">
|
||||
<span>{{ __('Try advanced search') }}</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -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,36 +1,38 @@
|
||||
<div>
|
||||
<div class="mx-auto max-w-5xl px-4 space-y-6">
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="p-4 bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow flex flex-col sm:flex-row gap-3">
|
||||
|
||||
<!-- Search -->
|
||||
<div class="space-y-5">
|
||||
{{-- Filters --}}
|
||||
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
{{-- Search --}}
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
wire:model.live.debounce.500ms="commentSearch"
|
||||
type="search"
|
||||
placeholder="Search comments..."
|
||||
class="w-full pl-10 pr-4 py-3 rounded-lg border-neutral-300 dark:text-neutral-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
wire:model.live.debounce.500ms="commentSearch"
|
||||
type="search"
|
||||
placeholder="Search comments..."
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-xl border-neutral-300 dark:text-neutral-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Order -->
|
||||
{{-- Order --}}
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
|
||||
<select
|
||||
wire:model.live="order"
|
||||
class="px-4 py-3 rounded-lg border-neutral-300 dark:text-gray-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 min-w-[128px]"
|
||||
class="appearance-none pl-10 pr-8 py-2.5 rounded-xl border-neutral-300 dark:text-gray-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 min-w-[128px]"
|
||||
>
|
||||
<option value="created_at_desc">Newest</option>
|
||||
<option value="created_at_asc">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="space-y-4">
|
||||
{{-- Comments --}}
|
||||
<div class="space-y-3">
|
||||
@forelse ($comments as $comment)
|
||||
|
||||
@php
|
||||
@@ -46,35 +48,40 @@
|
||||
wire:key="comment-{{ $comment->id }}"
|
||||
class="block group">
|
||||
|
||||
<div class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
|
||||
<div class="rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 overflow-hidden">
|
||||
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="sm:w-48 shrink-0">
|
||||
{{-- Thumbnail --}}
|
||||
<div class="sm:w-44 shrink-0">
|
||||
<img
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}"
|
||||
alt=""
|
||||
class="w-full h-40 sm:h-full object-cover"
|
||||
alt="{{ $episode->title ?? '' }}"
|
||||
class="w-full h-36 sm:h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 p-4 flex flex-col justify-between">
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
|
||||
|
||||
<!-- Comment -->
|
||||
<div class="text-gray-800 dark:text-gray-200 text-sm line-clamp-3">
|
||||
{{-- Episode Title --}}
|
||||
<p class="text-xs font-medium text-rose-600 dark:text-rose-400 truncate mb-1">
|
||||
{{ $episode->title ?? 'Episode' }}
|
||||
</p>
|
||||
|
||||
{{-- Comment --}}
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 line-clamp-2 leading-relaxed">
|
||||
{!! $comment->presenter()->markdownBody() !!}
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center justify-between mt-3 text-xs text-gray-700 dark:text-gray-400">
|
||||
|
||||
<span>
|
||||
{{-- Meta --}}
|
||||
<div class="flex items-center justify-between mt-2.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="fa-solid fa-clock text-[10px]"></i>
|
||||
{{ $comment->presenter()->relativeCreatedAt() }}
|
||||
</span>
|
||||
|
||||
<span class="text-rose-600 font-medium group-hover:underline">
|
||||
<span class="text-rose-600 dark:text-rose-400 font-medium group-hover:underline">
|
||||
View comment
|
||||
</span>
|
||||
</div>
|
||||
@@ -83,19 +90,19 @@
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="flex bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
|
||||
<div class="text-gray-800 dark:text-gray-200 text-center w-full p-4">
|
||||
<p class="text-lg">No results</p>
|
||||
<p class="text-sm opacity-70">(╥﹏╥)</p>
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-comment-slash text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No comments found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You haven't commented on anything yet.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{-- Pagination --}}
|
||||
<div>
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,139 @@
|
||||
<div>
|
||||
<div class="md:ml-8 my-8 md:my-0 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[95%]">
|
||||
@include('livewire.partials.search-filter')
|
||||
<div class="space-y-5">
|
||||
{{-- Slim Profile Filter --}}
|
||||
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
{{-- Search --}}
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-4 w-4 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
wire:model.live.debounce.400ms="search"
|
||||
type="search"
|
||||
placeholder="Search liked episodes..."
|
||||
class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-11 pr-4 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Order --}}
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
|
||||
<select
|
||||
wire:model.live="order"
|
||||
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
<option value="recently-uploaded">{{ __('home.recently-uploaded') }}</option>
|
||||
<option value="recently-released">{{ __('home.recently-released') }}</option>
|
||||
<option value="oldest-uploads">{{ __('search.oldest-uploads') }}</option>
|
||||
<option value="oldest-releases">{{ __('search.oldest-releases') }}</option>
|
||||
<option value="view-count">{{ __('search.view-count') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- View --}}
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-list pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
|
||||
<select
|
||||
wire:model.live="view"
|
||||
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="thumbnail">Thumbnail</option>
|
||||
<option value="poster">Poster</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Filter Buttons --}}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalGenres"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-sliders text-[10px]"></i>
|
||||
Genres
|
||||
@if($tagcount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $tagcount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalBlacklist"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-shield text-[10px]"></i>
|
||||
Blacklist
|
||||
@if($blacklistcount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $blacklistcount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalStudios"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<i class="fa-solid fa-microphone-lines text-[10px]"></i>
|
||||
Studios
|
||||
@if($studiocount > 0)
|
||||
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $studiocount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Hide Watched --}}
|
||||
@auth
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 whitespace-nowrap">
|
||||
<input
|
||||
id="checkBoxHideWatched"
|
||||
type="checkbox"
|
||||
wire:model.live="hideWatched"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-rose-600 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
<span>Hide watched</span>
|
||||
</label>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modals --}}
|
||||
@include('modals.filter-genres')
|
||||
@include('modals.filter-studios')
|
||||
@include('modals.filter-blacklist')
|
||||
|
||||
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
|
||||
<div class="relative md:ml-8 pt-5 mx-auto space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[95%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
|
||||
{{-- Results --}}
|
||||
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex justify-center">
|
||||
<div class="mt-4">
|
||||
@if ($view == 'thumbnail')
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
@else
|
||||
<div class="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8 gap-2">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
@endif
|
||||
@forelse($episodes as $episode)
|
||||
@include('livewire.partials.search-result')
|
||||
@empty
|
||||
<div class="col-span-full">
|
||||
<p class="text-2xl w-52 pt-6">No results (╥﹏╥)</p>
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-heart-crack text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No liked episodes</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start exploring and like what you enjoy!</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
|
||||
</div>
|
||||
</div
|
||||
</div>
|
||||
</div>
|
||||
@@ -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">
|
||||
|
||||
{{-- Episodes Grid --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
@foreach ($episodes as $episode)
|
||||
<div class="mt-2 mb-6 ml-4">
|
||||
<x-episode-cover :episode="$episode->episode" view="thumbnail" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($watched->hasPages())
|
||||
<div class="mt-8">
|
||||
{{ $watched->links('pagination::tailwind') }}
|
||||
</div>
|
||||
</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>
|
||||
@@ -13,10 +13,10 @@
|
||||
|
||||
<!--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>
|
||||
<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">
|
||||
@@ -46,16 +46,25 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
<p class="px-4 text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">Or Create a New Playlist</p>
|
||||
@else
|
||||
<p class="px-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
No Playlists found. Create one below!
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<!-- Create new playlist -->
|
||||
<div class="p-4">
|
||||
<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="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="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">
|
||||
<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>
|
||||
@@ -69,40 +78,6 @@
|
||||
Create and Add Episode
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@else
|
||||
|
||||
<!-- Create Playlist -->
|
||||
<a class="font-semibold text-gray-800 dark:text-gray-200 leading-tight">
|
||||
No Playlists found. Please create a Playlist first!
|
||||
</a>
|
||||
|
||||
<div class="p-4">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Enter Playlist Name Here:</label>
|
||||
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" required autofocus/>
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 p-4">
|
||||
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="visiblity">Visiblity:</label>
|
||||
<select name="visiblity" id="visiblity" class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
<option value="public">Public</option>
|
||||
<option value="private" selected>Private</option>
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('visiblity')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<a id="playlist-cancel" class="inline-block cursor-pointer rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
|
||||
Cancel
|
||||
</a>
|
||||
<a id="playlist-create-and-add" class="ml-1 cursor-pointer inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
|
||||
Create and Add Episode
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
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">
|
||||
<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-->
|
||||
@@ -17,13 +17,13 @@
|
||||
@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>
|
||||
@@ -31,11 +31,16 @@
|
||||
<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>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@php
|
||||
$download = $hdl->getDownloadByType('FHDi');
|
||||
$downloadURL = $dldomains[array_rand($dldomains)].'/'.$download->url;
|
||||
$version = str_contains($download->url, 'v2') ? 'v2' : '';
|
||||
@endphp
|
||||
|
||||
<livewire:download-button
|
||||
@@ -14,5 +15,6 @@
|
||||
:download-count="$download->count"
|
||||
:episode-number="$hdl->episode"
|
||||
:fill-numbers="$fillNumbers"
|
||||
:file-size="$download->getFileSize()">
|
||||
:file-size="$download->getFileSize()"
|
||||
:version="$version">
|
||||
@endif
|
||||
@@ -10,6 +10,7 @@
|
||||
$now = \Illuminate\Support\Carbon::now();
|
||||
$expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6));
|
||||
$file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url);
|
||||
$version = str_contains($download->url, 'v2') ? 'v2' : '';
|
||||
|
||||
$downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire;
|
||||
@endphp
|
||||
@@ -20,5 +21,6 @@
|
||||
:download-count="$download->count"
|
||||
:episode-number="$hdl->episode"
|
||||
:fill-numbers="$fillNumbers"
|
||||
:file-size="$download->getFileSize()">
|
||||
:file-size="$download->getFileSize()"
|
||||
:version="$version">
|
||||
@endif
|
||||
@@ -10,6 +10,7 @@
|
||||
$now = \Illuminate\Support\Carbon::now();
|
||||
$expire = \Illuminate\Support\Facades\Crypt::encryptString($now->addHours(6));
|
||||
$file = \Illuminate\Support\Facades\Crypt::encryptString('hentai/'.$download->url);
|
||||
$version = str_contains($download->url, 'v2') ? 'v2' : '';
|
||||
|
||||
$downloadURL = $dlpdomains[array_rand($dlpdomains)].'/download/'.$file.'/'.$expire;
|
||||
@endphp
|
||||
@@ -20,5 +21,6 @@
|
||||
:download-count="$download->count"
|
||||
:episode-number="$hdl->episode"
|
||||
:fill-numbers="$fillNumbers"
|
||||
:file-size="$download->getFileSize()">
|
||||
:file-size="$download->getFileSize()"
|
||||
:version="$version">
|
||||
@endif
|
||||
@@ -1,20 +1,17 @@
|
||||
<head>
|
||||
<!-- Meta -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@if (Route::currentRouteName() == 'hentai.index')
|
||||
@if (isset($episode))
|
||||
<title>Watch {{ $episode->title }} - {{ $episode->episode }} in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
|
||||
@elseif (isset($hentai))
|
||||
<title>Watch {{ $hentai->episodes[0]->title }} in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
|
||||
@if (isset($socialSlot))
|
||||
{{ $socialSlot }}
|
||||
@else
|
||||
<title>Watch Highest Quality Hentai in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
|
||||
@endif
|
||||
@else
|
||||
<title>Watch Highest Quality Hentai in 4k, 1080p, UHD, HD for Free - hstream.moe</title>
|
||||
<meta name="description" content="The best free 4k hentai site you will ever need online! Free 1080p hentai downloads and 4k hentai streams!">
|
||||
@include('partials.social-home-preview')
|
||||
@endif
|
||||
|
||||
<!-- Sitemap -->
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link rel="preload" href="https://fonts.bunny.net/figtree/files/figtree-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||
@@ -37,11 +34,6 @@
|
||||
<meta name="msapplication-navbutton-color" content="#be123c">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#be123c">
|
||||
|
||||
<!-- OG -->
|
||||
@if (isset($episode) || isset($hentai))
|
||||
@include('partials.social-preview')
|
||||
@endif
|
||||
|
||||
<script>
|
||||
document.documentElement.classList.add('dark');
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
@php
|
||||
$title = '';
|
||||
if (! isset($episode) && isset($hentai)) {
|
||||
$episode = $hentai->episodes[0];
|
||||
$gallery = $episode->gallery;
|
||||
$title = "{$episode->title} - Watch All Episodes English Subbed in 4K | hstream.moe";
|
||||
$canonical = url('/hentai/'.$hentai->slug);
|
||||
} else {
|
||||
$title = "{$episode->title} Episode {$episode->episode} - English Subbed 4K Stream | hstream.moe";
|
||||
$canonical = url('/hentai/'.$episode->slug);
|
||||
}
|
||||
|
||||
$description = Str::limit(
|
||||
preg_replace('/\s+/', ' ', strip_tags($episode->description)),
|
||||
160
|
||||
);
|
||||
@endphp
|
||||
|
||||
<!-- Site meta -->
|
||||
<title>{{ $title }}</title>
|
||||
<link rel="canonical" href="{{ $canonical }}">
|
||||
<meta name="robots" content="index,follow,max-image-preview:large">
|
||||
<meta name="description" content="{{ $description }}">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ $title }}">
|
||||
<meta property="og:description" content="{{ $description }}">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:url" content="{{ $canonical }}">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:site_name" content="hstream.moe">
|
||||
|
||||
@if(isset($gallery) && $episode->gallery->isNotEmpty())
|
||||
<meta property="og:image" content="{{ url($gallery[0]->image_url) }}">
|
||||
<meta property="og:image:secure_url" content="{{ url($gallery[0]->image_url) }}">
|
||||
<meta property="og:image:alt" content="{{ $episode->title }} Episode {{ $episode->episode }}">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="720">
|
||||
<meta property="og:image:type" content="image/webp">
|
||||
@endif
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $title }}">
|
||||
<meta name="twitter:description" content="{{ $description }}">
|
||||
@if($episode->gallery->isNotEmpty())
|
||||
<meta name="twitter:image" content="{{ url($episode->gallery[0]->image_url) }}">
|
||||
<meta name="twitter:image:alt" content="{{ $episode->title }} Episode {{ $episode->episode }}">
|
||||
@endif
|
||||
|
||||
<!-- JSON-LD -->
|
||||
@if (!isset($hentai))
|
||||
@php
|
||||
$data = [
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "VideoObject",
|
||||
"name" => "$episode->title Episode $episode->episode",
|
||||
"description" => $description,
|
||||
"url" => $canonical,
|
||||
"uploadDate" => $episode->created_at->toIso8601String()
|
||||
];
|
||||
|
||||
if ( $episode->gallery->isNotEmpty() ) {
|
||||
$data["thumbnailUrl"] = url($episode->gallery[0]->image_url);
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
foreach($episode->tags as $tag) {
|
||||
$tags[] = $tag->name;
|
||||
}
|
||||
$data["genre"] = $tags;
|
||||
|
||||
$data["interactionStatistic"] = [
|
||||
"@type" => "InteractionCounter",
|
||||
"interactionType" => [
|
||||
"@type" => "WatchAction"
|
||||
],
|
||||
"userInteractionCount" => $episode->view_count
|
||||
];
|
||||
@endphp
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endif
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
<!-- Site meta -->
|
||||
<title>Watch English Subbed Hentai Online in HD & 4K | hstream.moe</title>
|
||||
<meta name="description" content="Stream English subbed hentai online in HD, Full HD and 4K quality. Browse series, episodes and playlists with regular updates.">
|
||||
<link rel="canonical" href="{{ url()->current() }}">
|
||||
|
||||
<!-- Indexing -->
|
||||
@php
|
||||
$noIndex = [
|
||||
'hentai.search',
|
||||
'playlist.index'
|
||||
]
|
||||
@endphp
|
||||
@if (in_array(Route::currentRouteName(), $noIndex))
|
||||
<meta name="robots" content="noindex,follow">
|
||||
@else
|
||||
<meta name="robots" content="index,follow">
|
||||
@endif
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Watch English Subbed Hentai Online in HD & 4K | hstream.moe">
|
||||
<meta property="og:description" content="Stream English subbed hentai online in HD, Full HD and 4K quality. Browse series, episodes and playlists with regular updates.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://hstream.moe/">
|
||||
<meta property="og:image" content="https://hstream.moe/images/cropped-HS-1-192x192.webp">
|
||||
<meta property="og:image:alt" content="hstream.moe">
|
||||
<meta property="og:image:width" content="192">
|
||||
<meta property="og:image:height" content="192">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:site_name" content="hstream.moe">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@@ -1,52 +0,0 @@
|
||||
@if (Route::currentRouteName() == 'hentai.index')
|
||||
@php
|
||||
$title = '';
|
||||
if (! isset($episode) && isset($hentai)) {
|
||||
$episode = $hentai->episodes[0];
|
||||
$gallery = $episode->gallery;
|
||||
$title = 'Watch all episodes from '.$episode->title.' in 4k, 2160p, 1080p, UHD, HD - hstream.moe';
|
||||
} else {
|
||||
$title = 'Watch '.$episode->title.' - '.$episode->episode.' in 4k, 2160p, 1080p, UHD, HD - hstream.moe';
|
||||
}
|
||||
@endphp
|
||||
|
||||
<meta property="og:title" content="{{ $title }}" />
|
||||
<meta property="og:description" content="{{ Str::limit($episode->description, 250) }}" />
|
||||
<meta property="og:type" content="video.episode" />
|
||||
<meta property="og:url" content="https://hstream.moe/hentai/{{ $episode->slug }}" />
|
||||
@if(isset($gallery) && $episode->gallery->isNotEmpty())
|
||||
<meta property="og:image" content="https://hstream.moe{{ $gallery[0]->image_url }}" />
|
||||
@endif
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="hstream.moe" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
@php
|
||||
$tags = '';
|
||||
foreach ($episode->tags as $tag) {
|
||||
if (!$tags){
|
||||
$tags .= $tag->name;
|
||||
} else {
|
||||
$tags .= ', '.$tag->name;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<meta name="description" content="{{ $episode->description }} {{ $tags }}">
|
||||
<meta name="keywords" content="hstream,hentaistream,hentai,stream,4k,hentai 4k,4k hentai,download,free">
|
||||
|
||||
@elseif (Route::currentRouteName() == 'home.index')
|
||||
|
||||
<meta property="og:title" content="Watch Hentai in 4k, 1080p, 720p, UHD, HD for Free! - hstream.moe" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://hstream.moe/" />
|
||||
<meta property="og:image" content="https://hstream.moe/images/cropped-HS-1-192x192.webp" />
|
||||
<meta property="og:image:width" content="300" />
|
||||
<meta property="og:image:height" content="100" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="hstream.moe" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="description" content="Watch Hentai free download in 4k, 2160p, 1080p, UHD, HD! Daily upload, no ads, subbed in 2160p!">
|
||||
@else
|
||||
<meta name="description" content="Watch Hentai free download in 4k, 2160p, 1080p, UHD, HD! Daily upload, no ads, subbed in 2160p!">
|
||||
@endif
|
||||
@@ -1,12 +1,12 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div
|
||||
class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-20 mt-[65px] flex flex-row justify-center md:justify-normal">
|
||||
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-comment text-rose-500"></i>
|
||||
{{ __('nav.comments') }}
|
||||
</h2>
|
||||
|
||||
{{-- Content from Livewire --}}
|
||||
<livewire:user-comments :model="$user"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,9 +1,123 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
|
||||
<div class="grid md:grid-flow-col gap-4 w-5/6 flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
@include('profile.partials.info')
|
||||
<x-profile-layout>
|
||||
<div class="space-y-6">
|
||||
{{-- Welcome Banner --}}
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-rose-600 via-rose-700 to-pink-700 p-6 sm:p-8 text-white shadow-xl shadow-rose-600/20">
|
||||
<div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMiIvPjwvZz48L2c+PC9zdmc+')] opacity-50"></div>
|
||||
<div class="relative z-10">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold">Welcome back, {{ $user->name }}!</h1>
|
||||
<p class="mt-2 text-rose-100 text-sm sm:text-base max-w-4xl">
|
||||
Here's an overview of your activity on hstream.moe. Dive back into your favorites or discover something new.
|
||||
</p>
|
||||
</div>
|
||||
{{-- Decorative circles --}}
|
||||
<div class="absolute -top-10 -right-10 h-40 w-40 rounded-full bg-white/5 blur-2xl"></div>
|
||||
<div class="absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-white/5 blur-2xl"></div>
|
||||
</div>
|
||||
|
||||
{{-- Quick Links Grid --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<a href="{{ route('profile.likes') }}"
|
||||
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/40 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200">
|
||||
<i class="fa-solid fa-heart text-lg"></i>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Liked Episodes</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->likes()) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes you've liked</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('user.watched') }}"
|
||||
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-400 group-hover:scale-110 transition-transform duration-200">
|
||||
<i class="fa-solid fa-eye text-lg"></i>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Watch History</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->watched->count()) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes watched</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.playlists') }}"
|
||||
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-400 group-hover:scale-110 transition-transform duration-200">
|
||||
<i class="fa-solid fa-rectangle-list text-lg"></i>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Your Playlists</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->playlists->count()) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Custom collections</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Recent Activity Section --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-5 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-5 flex items-center gap-2">
|
||||
<i class="fa-solid fa-clock-rotate-left text-rose-500"></i>
|
||||
Recent Activity
|
||||
</h3>
|
||||
|
||||
@php
|
||||
$recentWatched = $user->watched()->with('episode')->latest()->take(4)->get();
|
||||
$recentComments = $user->comments()->latest()->take(2)->get();
|
||||
@endphp
|
||||
|
||||
@if($recentWatched->isEmpty() && $recentComments->isEmpty())
|
||||
<div class="text-center py-10">
|
||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-ghost text-2xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">No activity yet. Start watching something!</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
{{-- Recently Watched --}}
|
||||
@if($recentWatched->isNotEmpty())
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recently Watched</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
@foreach($recentWatched as $watched)
|
||||
<a href="{{ route('hentai.index', ['title' => $watched->episode->slug]) }}"
|
||||
class="group relative overflow-hidden rounded-lg bg-gray-100 dark:bg-neutral-800 aspect-video block">
|
||||
<img src="{{ $watched->episode->gallery->first()->thumbnail_url ?? '/images/default-avatar.webp' }}"
|
||||
alt="{{ $watched->episode->title }}"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<p class="absolute bottom-2 left-2 right-2 text-xs text-white font-medium truncate">
|
||||
{{ $watched->episode->title }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@endif
|
||||
|
||||
{{-- Recent Comments --}}
|
||||
@if($recentComments->isNotEmpty())
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recent Comments</p>
|
||||
<div class="space-y-2">
|
||||
@foreach($recentComments as $comment)
|
||||
<a href="{{ route('hentai.index', ['title' => $comment->commentable->slug ?? '#']) }}#comment-{{ $comment->id }}"
|
||||
class="block rounded-lg bg-gray-50/70 dark:bg-neutral-900/50 p-3 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 line-clamp-2">
|
||||
{!! $comment->presenter()->markdownBody() !!}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-400/80 mt-1.5">
|
||||
{{ $comment->presenter()->relativeCreatedAt() }}
|
||||
</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-profile-layout>
|
||||
@@ -1,9 +1,14 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-heart text-rose-500"></i>
|
||||
{{ __('nav.likes') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{{-- Content from Livewire --}}
|
||||
@livewire('user-likes')
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,55 +1,84 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div
|
||||
class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
|
||||
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-bell text-rose-500"></i>
|
||||
Notifications
|
||||
</h2>
|
||||
@if($notifications->isNotEmpty())
|
||||
<form method="POST" action="{{ route('profile.notifications.delete') }}" class="hidden sm:block">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-800/50 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors">
|
||||
<i class="fa-solid fa-trash-can text-[10px]"></i>
|
||||
Clear All
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@forelse($notifications as $notification)
|
||||
<div
|
||||
class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur border border-gray-200 dark:border-neutral-700 rounded-xl shadow-sm p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-x-4 transition hover:shadow-md">
|
||||
<div class="group relative rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md transition-all duration-200 overflow-hidden">
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-2 w-full h-full mt-2">
|
||||
<div class="flex items-center justify-between flex-none h-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-sky-600 dark:text-rose-500">
|
||||
{{-- Unread indicator --}}
|
||||
@if(is_null($notification->read_at))
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-rose-500"></div>
|
||||
@endif
|
||||
|
||||
<div class="p-4 sm:p-5 {{ is_null($notification->read_at) ? 'pl-5 sm:pl-6' : '' }}">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-rose-100 dark:bg-rose-900/30 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-rose-700 dark:text-rose-400">
|
||||
<i class="fa-solid fa-tag text-[9px]"></i>
|
||||
{{ $notification->data['type'] ?? 'Notification' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $notification->created_at->diffForHumans(['parts' => 1]) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm sm:text-base text-gray-700 dark:text-gray-300 leading-relaxed h-full">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{{ $notification->data['message'] ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 sm:gap-2 shrink-0">
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
@if(isset($notification->data['url']))
|
||||
<a href="{{ $notification->data['url'] }}"
|
||||
class="text-center rounded-lg bg-sky-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-sky-700 transition">
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-rose-700 shadow-sm shadow-rose-600/20 transition-all duration-150 hover:shadow-md hover:shadow-rose-600/25">
|
||||
Open
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('profile.notifications.delete') }}">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<input type="hidden" value="{{ $notification->id }}" name="id">
|
||||
|
||||
<button type="submit"
|
||||
class="w-full rounded-lg bg-rose-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-rose-700 transition">
|
||||
Delete
|
||||
class="inline-flex items-center rounded-lg p-2 text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/30 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Delete">
|
||||
<i class="fa-solid fa-xmark text-sm"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
|
||||
<p class="text-lg">No notifications</p>
|
||||
<p class="text-sm opacity-70">(╥﹏╥)</p>
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-bell-slash text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No notifications</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You're all caught up! Nothing new here.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,22 +1,26 @@
|
||||
@auth
|
||||
<div
|
||||
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
|
||||
<div class="flex flex-col p-2">
|
||||
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.subscription')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
|
||||
href="{{ route('profile.subscription') }}"><i class="fa-solid fa-hand-holding-dollar pr-4"></i></i>
|
||||
Subscription</a>
|
||||
<div class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
|
||||
<div class="flex flex-col p-1.5">
|
||||
|
||||
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.settings')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
|
||||
href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i>
|
||||
Settings</a>
|
||||
<a href="{{ route('profile.settings') }}"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.settings'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-gear w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.settings')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit"
|
||||
class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-right-from-bracket pr-4"></i>
|
||||
Logout</button>
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-red-50/60 dark:hover:bg-red-950/40 hover:text-red-600 dark:hover:text-red-400 border-l-[3px] border-transparent transition-all duration-150">
|
||||
<i class="fa-solid fa-right-from-bracket w-5 text-center text-base text-gray-400 dark:text-gray-500"></i>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<div class="md:hidden mt-5 -mx-1">
|
||||
<nav class="flex gap-1 overflow-x-auto pb-2 scrollbar-hide">
|
||||
<a href="{{ route('profile.show') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.show'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-user text-[10px]"></i>
|
||||
{{ __('nav.profile') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.notifications') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.notifications'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-bell text-[10px]"></i>
|
||||
Notifications
|
||||
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
|
||||
@if($unreadCount > 0)
|
||||
<span class="inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-rose-500 px-1 text-[9px] font-bold text-white">
|
||||
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.likes') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.likes'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-heart text-[10px]"></i>
|
||||
{{ __('nav.likes') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('user.watched') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('user.watched'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-eye text-[10px]"></i>
|
||||
{{ __('nav.watched') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.comments') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.comments'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-comment text-[10px]"></i>
|
||||
{{ __('nav.comments') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.playlists') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.playlists'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-rectangle-list text-[10px]"></i>
|
||||
{{ __('nav.playlists') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.settings') }}"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.settings'))
|
||||
bg-rose-600 text-white shadow-md shadow-rose-600/25
|
||||
@else
|
||||
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
|
||||
@endif">
|
||||
<i class="fa-solid fa-gear text-[10px]"></i>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1,15 +1,68 @@
|
||||
<div
|
||||
class="overflow-hidden relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl flex items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
|
||||
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="{{ $user->getAvatar() }}">
|
||||
<div class="flex flex-col py-5 pl-24">
|
||||
<strong class="text-slate-900 text-xl font-bold dark:text-slate-200">
|
||||
{{ $user->name }}
|
||||
@if ($user->hasRole(\App\Enums\UserRole::SUPPORTER))
|
||||
<a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i
|
||||
class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a>
|
||||
<div class="overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
|
||||
<div class="relative">
|
||||
{{-- Profile Banner Gradient --}}
|
||||
<div class="h-20 bg-gradient-to-br from-rose-500 via-rose-600 to-pink-600 dark:from-rose-700 dark:via-rose-800 dark:to-pink-800"></div>
|
||||
|
||||
{{-- Avatar overlapping the banner --}}
|
||||
<div class="flex justify-center -mt-10">
|
||||
<div class="relative">
|
||||
<img class="h-20 w-20 rounded-full border-4 border-white dark:border-neutral-900 shadow-lg object-cover bg-white dark:bg-neutral-800"
|
||||
src="{{ auth()->user()->getAvatar() }}"
|
||||
alt="{{ auth()->user()->name }}">
|
||||
@if(auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER))
|
||||
<span class="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full bg-rose-600 text-white shadow-md ring-2 ring-white dark:ring-neutral-900"
|
||||
data-te-toggle="tooltip"
|
||||
title="Badge of appreciation for the horny people supporting us! :3">
|
||||
<i class="fa-solid fa-heart text-[11px]"></i>
|
||||
</span>
|
||||
@endif
|
||||
</strong>
|
||||
<span class="text-slate-500 text-sm font-medium dark:text-slate-400">Joined
|
||||
{{ $user->created_at->format('Y-m') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- User Info --}}
|
||||
<div class="px-4 pb-4 pt-2 text-center">
|
||||
<h2 class="text-base font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ auth()->user()->name }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
Joined {{ auth()->user()->created_at->format('F Y') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Quick Stats Row --}}
|
||||
<div class="grid grid-cols-4 border-t border-gray-200/60 dark:border-neutral-800/60">
|
||||
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
|
||||
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
{{ number_format(auth()->user()->watched->count()) }}
|
||||
</div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
|
||||
Views
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
|
||||
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
{{ number_format(auth()->user()->commentCount()) }}
|
||||
</div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
|
||||
Cmts
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
|
||||
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
{{ number_format(auth()->user()->likes()) }}
|
||||
</div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
|
||||
Likes
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
|
||||
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
{{ number_format(auth()->user()->playlists->count()) }}
|
||||
</div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
|
||||
Lists
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,37 +1,96 @@
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
{{-- Profile Card --}}
|
||||
@include('profile.partials.profile')
|
||||
|
||||
<div
|
||||
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
|
||||
<div class="flex flex-col p-2">
|
||||
{{-- Desktop Navigation (hidden on mobile) --}}
|
||||
<div class="hidden md:block">
|
||||
<nav
|
||||
class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
|
||||
<div class="flex flex-col p-1.5">
|
||||
<a href="{{ route('profile.show') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.show')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-user pr-4"></i> {{ __('nav.profile') }}</a>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.show'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-user w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.show')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>{{ __('nav.profile') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.notifications') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.notifications')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-bell pr-4"></i> Notifications</a>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.notifications'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-bell w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.notifications')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>Notifications</span>
|
||||
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
|
||||
@if($unreadCount > 0)
|
||||
<span class="ml-auto inline-flex items-center justify-center h-5 min-w-[20px] rounded-full bg-rose-600 px-1.5 text-[10px] font-bold text-white">
|
||||
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.likes') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.likes')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}</a>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.likes'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-heart w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.likes')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>{{ __('nav.likes') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('user.watched') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('user.watched')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-eye pr-4"></i>
|
||||
{{ __('nav.watched') }}</a>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('user.watched'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-eye w-5 text-center text-base
|
||||
@if(request()->routeIs('user.watched')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>{{ __('nav.watched') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.comments') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.comments')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-comment pr-4"></i>
|
||||
{{ __('nav.comments') }}</a>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.comments'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-comment w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.comments')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>{{ __('nav.comments') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('profile.playlists') }}"
|
||||
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.playlists')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
|
||||
class="fa-solid fa-rectangle-list pr-4"></i>
|
||||
{{ __('nav.playlists') }}</a>
|
||||
</div>
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@if(request()->routeIs('profile.playlists'))
|
||||
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
|
||||
@else
|
||||
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
|
||||
@endif">
|
||||
<i class="fa-solid fa-rectangle-list w-5 text-center text-base
|
||||
@if(request()->routeIs('profile.playlists')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
|
||||
<span>{{ __('nav.playlists') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- Actions --}}
|
||||
@include('profile.partials.actions')
|
||||
</div>
|
||||
|
||||
{{-- Mobile Navigation (horizontal scroll tabs, visible only on mobile) --}}
|
||||
@include('profile.partials.mobile-nav')
|
||||
</div>
|
||||
@@ -1,64 +1,151 @@
|
||||
<div>
|
||||
<div class="grid-cols-1 sm:grid md:grid-cols-3 ">
|
||||
@if(count($playlists) > 0)
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@foreach($playlists as $playlist)
|
||||
@php
|
||||
$count = $playlist->episodes->count();
|
||||
@endphp
|
||||
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/60 sm:shrink-0 sm:grow sm:basis-0">
|
||||
@if($count > 0)
|
||||
<a href="{{ route('profile.playlist.show', $playlist->id) }}">
|
||||
@else
|
||||
<a href="#!">
|
||||
@endif
|
||||
<div class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 flex flex-col">
|
||||
{{-- Thumbnail --}}
|
||||
@if($count > 0)
|
||||
<a href="{{ route('profile.playlist.show', $playlist->id) }}" class="block overflow-hidden">
|
||||
@php
|
||||
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position', 'desc')->first();
|
||||
@endphp
|
||||
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}" alt="Hollywood Sign on The Hill" />
|
||||
@else
|
||||
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
|
||||
@endif
|
||||
<img class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
|
||||
alt="{{ $playlist->name }}"
|
||||
loading="lazy" />
|
||||
</a>
|
||||
<div class="p-6">
|
||||
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
|
||||
@else
|
||||
<div class="w-full aspect-video bg-gray-200 dark:bg-neutral-800 flex items-center justify-center">
|
||||
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp"
|
||||
class="w-full aspect-video object-cover opacity-30 dark:opacity-15"
|
||||
alt="Empty playlist" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="p-4 flex-1 flex flex-col"
|
||||
x-data="{ editing: false, name: '{{ $playlist->name }}', isPrivate: {{ $playlist->is_private ? 'true' : 'false' }} }">
|
||||
|
||||
{{-- Edit mode --}}
|
||||
<template x-if="editing">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<form method="POST" action="{{ route('profile.playlist.update', $playlist->id) }}" class="flex-1 flex flex-col">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="space-y-3 flex-1">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
x-model="name"
|
||||
maxlength="30"
|
||||
required
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white focus:border-rose-500 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Visibility</label>
|
||||
<select
|
||||
name="is_private"
|
||||
x-model="isPrivate"
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white focus:border-rose-500 focus:ring-rose-500"
|
||||
>
|
||||
<option value="0">Public</option>
|
||||
<option value="1">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<button type="submit" class="cursor-pointer rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-700 flex-1">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" @click="editing = false; name = '{{ $playlist->name }}'; isPrivate = {{ $playlist->is_private ? 'true' : 'false' }}" class="cursor-pointer rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-600 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800 flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Display mode --}}
|
||||
<template x-if="!editing">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="flex-1">
|
||||
<h5 class="text-base font-semibold text-neutral-800 dark:text-neutral-50 truncate">
|
||||
{{ $playlist->name }}
|
||||
</h5>
|
||||
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
|
||||
{{ $count }} Episodes - {{ $playlist->is_private == 1 ? 'Private' : 'Public' }}
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full bg-neutral-100 dark:bg-neutral-700 px-2 py-0.5 text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<i class="fa-solid fa-film text-[9px]"></i>
|
||||
{{ $count }} {{ Str::plural('ep', $count) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full text-[11px] font-medium
|
||||
{{ $playlist->is_private ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' }}">
|
||||
<i class="fa-solid fa-{{ $playlist->is_private ? 'lock' : 'globe' }} text-[9px]"></i>
|
||||
{{ $playlist->is_private ? 'Private' : 'Public' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('profile.playlist.delete', $playlist->id) }}"
|
||||
class="inline-flex items-center gap-1 text-[11px] text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
data-confirm-delete="true">
|
||||
<i class="fa-solid fa-trash-can text-[10px]"></i>
|
||||
Delete
|
||||
</a>
|
||||
<button @click="editing = true"
|
||||
class="inline-flex items-center gap-1 text-[11px] text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors">
|
||||
<i class="fa-solid fa-pen-to-square text-[10px]"></i>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
@if($count > 0)
|
||||
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}" class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a>
|
||||
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
|
||||
<i class="fa-solid fa-play text-[9px]"></i>
|
||||
Play
|
||||
</a>
|
||||
@endif
|
||||
</p>
|
||||
<a href="{{ route('profile.playlist.delete', $playlist->id) }}" class="inline-flex items-center cursor-pointer text-xs text-red-600" data-confirm-delete="true">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
<!-- Add Another Playlist -->
|
||||
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/60 sm:shrink-0 sm:grow sm:basis-0">
|
||||
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-40" alt="..." />
|
||||
<div class="p-6">
|
||||
<p class="text-black dark:text-white">
|
||||
Create another Playlist
|
||||
</p>
|
||||
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
|
||||
Create
|
||||
</a>
|
||||
|
||||
{{-- Add New Playlist Card --}}
|
||||
<button
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalCreatePlaylist"
|
||||
class="group rounded-xl border-2 border-dashed border-neutral-300 dark:border-neutral-700 bg-white/20 dark:bg-neutral-950/20 backdrop-blur hover:border-rose-400 dark:hover:border-rose-600 hover:bg-rose-50/50 dark:hover:bg-rose-950/20 transition-all duration-200 flex flex-col items-center justify-center p-8 min-h-[200px]">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200 mb-3">
|
||||
<i class="fa-solid fa-plus text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-neutral-600 dark:text-neutral-300">Create Playlist</p>
|
||||
<p class="text-xs text-neutral-400 dark:text-neutral-500 mt-1">Organize your favorites</p>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<!-- No Playlist Found -->
|
||||
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-700 sm:shrink-0 sm:grow sm:basis-0">
|
||||
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
|
||||
<div class="p-6">
|
||||
<p class="text-black dark:text-white">
|
||||
No Playlist found!
|
||||
</p>
|
||||
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
|
||||
Create
|
||||
</a>
|
||||
{{-- Empty State --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-rectangle-list text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No playlists yet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 mb-6">Create your first playlist to organize your favorite episodes.</p>
|
||||
<button
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalCreatePlaylist"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
Create your first playlist
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,14 +1,24 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-rectangle-list text-rose-500"></i>
|
||||
{{ __('nav.playlists') }}
|
||||
</h2>
|
||||
<button
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalCreatePlaylist"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-rose-600/20 hover:bg-rose-700 transition-colors">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
New Playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
@include('profile.partials.user-playlists')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modal --}}
|
||||
@include('modals.create-playlist')
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,30 +1,126 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 mb-14 flex flex-row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-gear text-rose-500"></i>
|
||||
Settings
|
||||
</h2>
|
||||
|
||||
{{-- Settings Sections --}}
|
||||
<div class="space-y-5">
|
||||
|
||||
{{-- Profile Information --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||
<i class="fa-solid fa-user-pen text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Profile Information</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Update your name, email and avatar</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
</div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
</div>
|
||||
|
||||
{{-- Passkeys --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400">
|
||||
<i class="fa-solid fa-key text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Passkeys</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Manage WebAuthn passkeys for passwordless login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
<livewire:passkeys />
|
||||
</div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
</div>
|
||||
|
||||
{{-- Password --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<i class="fa-solid fa-lock text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Update Password</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Keep your account secure</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
@include('profile.partials.update-password-form')
|
||||
</div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
</div>
|
||||
|
||||
{{-- Search Blacklist --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||
<i class="fa-solid fa-shield text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Search Blacklist</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Hide content with specific tags from search results</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
@include('profile.partials.update-blacklist-form')
|
||||
</div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
</div>
|
||||
|
||||
{{-- Website Design --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<i class="fa-solid fa-object-group text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Website Design</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Customize your browsing experience</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
@include('profile.partials.update-design-form')
|
||||
</div>
|
||||
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
</div>
|
||||
|
||||
{{-- Danger Zone --}}
|
||||
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-red-200/60 dark:ring-red-800/30 overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-red-200/60 dark:border-red-800/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
|
||||
<i class="fa-solid fa-triangle-exclamation text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-red-700 dark:text-red-400">Danger Zone</h3>
|
||||
<p class="text-xs text-red-500 dark:text-red-400">Irreversible actions - proceed with caution</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6">
|
||||
@include('profile.partials.delete-user-form')
|
||||
</div>
|
||||
@include('profile.partials.delete-user-modal')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Delete Account Modal --}}
|
||||
@include('profile.partials.delete-user-modal')
|
||||
|
||||
@vite(['resources/js/user-blacklist.js'])
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,11 +0,0 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
|
||||
@livewire('user-subscription', ['user' => $user])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,9 +1,12 @@
|
||||
<x-app-layout>
|
||||
@include('partials.background')
|
||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@include('profile.partials.sidebar')
|
||||
<x-profile-layout>
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fa-solid fa-eye text-rose-500"></i>
|
||||
{{ __('nav.watched') }}
|
||||
</h2>
|
||||
|
||||
{{-- Content from Livewire --}}
|
||||
@livewire('watched', ['user' => $user])
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-profile-layout>
|
||||
@@ -1,9 +1,14 @@
|
||||
<x-app-layout>
|
||||
|
||||
<x-slot name="socialSlot">
|
||||
@include('partials.social-hentai-preview', ['hentai' => $hentai])
|
||||
</x-slot>
|
||||
|
||||
@php $episode = $hentai->episodes[0]; @endphp
|
||||
<div class="pt-6">
|
||||
<div class="flex flex-col lg:flex-row justify-center">
|
||||
<div class="pt-2 sm:px-2 lg:px-4 space-y-6 max-w-[100%] xl:max-w-[70%] 2xl:max-w-[60%] z-10">
|
||||
@include('series.partials.info')
|
||||
@include('stream.partials.info', ['streamPage' => false])
|
||||
|
||||
@include('series.partials.episodes')
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800 p-5">
|
||||
<a class="leading-normal font-bold text-lg text-rose-600">
|
||||
<div class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm backdrop-blur-sm transition-colors dark:border-white/10 dark:bg-neutral-900/80">
|
||||
<div class="p-5 md:p-7">
|
||||
<a class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ __('home.episodes') }} ({{ $hentai->episodes->count() }})
|
||||
</a>
|
||||
|
||||
<!-- Episode List -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2 mt-4">
|
||||
@foreach ($hentai->episodes as $episode)
|
||||
<x-episode-cover :episode="$episode" view="thumbnail" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-800 p-5">
|
||||
<!-- Cover -->
|
||||
<div class="w-[100px] md:w-[150px] mr-4 float-left">
|
||||
<img alt="{{ $episode->title }}" loading="lazy" width="150"
|
||||
class="block relative rounded-lg object-cover object-center aspect-[11/16] z-20"
|
||||
src="{{ $episode->cover_url }}"></img>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="relative">
|
||||
<!-- Title -->
|
||||
<h1 class="text-3xl font-bold text-rose-600">
|
||||
{{ $episode->title }} ({{ $episode->title_jpn }})
|
||||
</h1>
|
||||
<div>
|
||||
<a data-te-toggle="tooltip"
|
||||
title="Released {{ \Carbon\Carbon::parse($episode->release_date)->diffForHumans(['parts' => 2]) }}"
|
||||
class="text-l text-gray-800 dark:text-white leading-tight pl-1">
|
||||
<i class="fa-regular fa-calendar"></i> {{ $episode->release_date }}
|
||||
|
|
||||
</a>
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
|
||||
class="text-l text-gray-800 dark:text-white leading-tight hover:underline hover:underline-offset-4">
|
||||
{{ $episode->studio->name }}
|
||||
</a>
|
||||
</div>
|
||||
<hr class="border-gray-400/40 mt-2 mb-2">
|
||||
<p class="leading-normal font-bold text-lg text-rose-600">
|
||||
{{ __('stream.description') }}
|
||||
</p>
|
||||
<p class="text-gray-800 dark:text-gray-200 leading-tight min-h-[50%]">
|
||||
{{ $hentai->description }}
|
||||
</p>
|
||||
<hr class="border-gray-400/40 mt-2 mb-1">
|
||||
<ul class="list-none text-center" style="overflow: hidden;">
|
||||
<a class="text-gray-400">
|
||||
|
|
||||
</a>
|
||||
@foreach ($episode->tags->sortBy('slug') as $tag)
|
||||
<li class="inline-block p-1">
|
||||
@if ($tag->slug == 'uncensored' || $tag->slug == 'vanilla' || $tag->slug == '4k')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-green-500 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-green-700 focus:bg-green-700 active:bg-green-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
@elseif($tag->slug == 'censored')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-yellow-600 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-yellow-700 focus:bg-yellow-700 active:bg-yellow-900 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
@elseif($tag->slug == 'gore' || $tag->slug == 'horror' || $tag->slug == 'scat' || $tag->slug == 'ntr' || $tag->slug == 'rape')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs text-red-600 dark:hover:text-white hover:text-white uppercase tracking-widest hover:bg-red-700 focus:bg-red-700 active:bg-red-900 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ $tag->name }}
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="relative block items-center px-2 py-2 mt-1 dark:focus:ring-offset-gray-800 border border-transparent rounded-md font-semibold text-xs dark:text-white hover:text-white uppercase tracking-widest hover:bg-rose-700 focus:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
<a class="text-gray-400">
|
||||
|
|
||||
</a>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,9 @@
|
||||
<x-app-layout>
|
||||
|
||||
<x-slot name="socialSlot">
|
||||
@include('partials.social-hentai-preview', ['episode' => $episode])
|
||||
</x-slot>
|
||||
|
||||
<div class="pt-10">
|
||||
<div class="flex flex-col xl:flex-row justify-center">
|
||||
@if($episode->is_dvd_aspect)
|
||||
@@ -15,7 +20,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('admin.stream')
|
||||
<!-- Infos -->
|
||||
@include('stream.partials.info')
|
||||
<!-- Comments -->
|
||||
@@ -38,18 +42,18 @@
|
||||
@include('modals.add-to-playlist')
|
||||
@include('modals.share')
|
||||
|
||||
|
||||
@auth
|
||||
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || Auth::user()->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||
@include('admin.modals.edit-episode')
|
||||
@endif
|
||||
|
||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
@if (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
@include('admin.modals.upload-episode')
|
||||
@include('admin.modals.add-subtitles')
|
||||
@endif
|
||||
|
||||
@if (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || auth()->user()->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||
@include('admin.modals.edit-episode')
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
<!-- Player Script -->
|
||||
@vite(['resources/js/player.js'])
|
||||
</x-app-layout>
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
<div class="bg-transparent rounded-lg">
|
||||
<div class="px-1 sm:px-2">
|
||||
<p class="leading-normal font-bold text-lg text-gray-900 dark:text-gray-200">
|
||||
{{ __('stream.gallery') }}
|
||||
</p>
|
||||
</div>
|
||||
@if ($gallery->count() > 5)
|
||||
<div class="grid grid-rows-1 w-30 text-left">
|
||||
<ul data-te-lightbox-init class="list-none text-center" style="overflow: hidden;">
|
||||
@php $counter = 0; @endphp
|
||||
@foreach($gallery as $image)
|
||||
@php $counter++; @endphp
|
||||
<li class="inline-block m-1 w-[45%] sm:w-[45%] md:w-[20%] xl:w-[18%]">
|
||||
@if ($counter > 5)
|
||||
<div class="!visible hidden" id="collapseGallery" data-te-collapse-item>
|
||||
@else
|
||||
<div>
|
||||
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ __('stream.gallery') }}
|
||||
</h2>
|
||||
|
||||
@if ($gallery->count() > 5)
|
||||
<button
|
||||
id="galleryToggle"
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white transition">
|
||||
{{ __('home.show-more') }}
|
||||
</button>
|
||||
@endif
|
||||
<div class="py-2 mt-2">
|
||||
<img onClick="(function(){player.play(); player.pause(); })();" src="{{ $image->thumbnail_url }}" data-te-img="{{ $image->image_url }}" alt="{{ $episode->title }} - {{ $episode->episode }} - Screenshot {{ $counter }}" class="relative block items-center h-full w-full rounded-lg tracking-widest transition ease-in-out duration-150 cursor-zoom-in shadow-sm data-[te-lightbox-disabled]:cursor-auto" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-te-lightbox-init
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
|
||||
@foreach($gallery as $index => $image)
|
||||
|
||||
<div class="{{ $index >= 5 ? 'hidden extra-gallery-item' : '' }}">
|
||||
|
||||
<div
|
||||
class="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl dark:border-neutral-700 dark:bg-neutral-800">
|
||||
|
||||
<img
|
||||
onClick="(function(){player.play(); player.pause(); })();"
|
||||
src="{{ $image->thumbnail_url }}"
|
||||
data-te-img="{{ $image->image_url }}"
|
||||
alt="{{ $episode->title }} - Screenshot {{ $index + 1 }}"
|
||||
class="aspect-video w-full cursor-zoom-in object-cover transition duration-300 group-hover:scale-105"
|
||||
>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</div>
|
||||
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="grid grid-rows-1 w-30 text-center">
|
||||
<a id="galleryShowMore" data-te-collapse-init data-te-ripple-init data-te-ripple-color="light" href="#collapseGallery" role="button" aria-expanded="false" aria-controls="collapseGallery" class="text-sm float-right cursor-pointer text-rose-600">{{ __('home.show-more') }}</a>
|
||||
|
||||
</div>
|
||||
|
||||
@if ($gallery->count() > 5)
|
||||
<script>
|
||||
var state = 0;
|
||||
function toggleGallery() {
|
||||
if (state == 0) {
|
||||
document.getElementById('galleryShowMore').innerText = "{{ __('stream.show-less') }}";
|
||||
state = 1;
|
||||
} else {
|
||||
document.getElementById('galleryShowMore').innerText = "{{ __('home.show-more') }}";
|
||||
state = 0;
|
||||
}
|
||||
}
|
||||
document.getElementById('galleryShowMore').addEventListener('click', toggleGallery);
|
||||
const toggleBtn = document.getElementById('galleryToggle');
|
||||
const hiddenItems = document.querySelectorAll('.extra-gallery-item');
|
||||
|
||||
let expanded = false;
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
|
||||
expanded = !expanded;
|
||||
|
||||
hiddenItems.forEach(item => {
|
||||
item.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
toggleBtn.innerText = expanded
|
||||
? "{{ __('stream.show-less') }}"
|
||||
: "{{ __('home.show-more') }}";
|
||||
});
|
||||
</script>
|
||||
@else
|
||||
<div class="grid grid-rows-1 w-30 text-left">
|
||||
<ul data-te-lightbox-init class="list-none text-center" style="overflow: hidden;">
|
||||
@php $counter = 0; @endphp
|
||||
@foreach($gallery as $image)
|
||||
@php $counter++; @endphp
|
||||
<li class="inline-block m-1 w-[45%] sm:w-[45%] md:w-[20%] xl:w-[18%]">
|
||||
<div class="py-2 mt-2">
|
||||
<img onClick="(function(){player.play(); player.pause(); })();" src="{{ $image->thumbnail_url }}" data-te-img="{{ $image->image_url }}" alt="{{ $episode->title }} - {{ $episode->episode }} - Screenshot {{ $counter }}" class="relative block items-center h-full w-full rounded-lg tracking-widest transition ease-in-out duration-150 cursor-zoom-in shadow-sm data-[te-lightbox-disabled]:cursor-auto" />
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,167 +1,247 @@
|
||||
<div class="overflow-hidden p-5 bg-transparent bg-white rounded-xl dark:bg-neutral-800">
|
||||
@props([
|
||||
'streamPage' => true,
|
||||
])
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm backdrop-blur-sm transition-colors dark:border-white/10 dark:bg-neutral-900/80">
|
||||
|
||||
<div class="p-5 md:p-7">
|
||||
@if($streamPage)
|
||||
<input id="e_id" type="hidden" value="{{ $episode->id }}" />
|
||||
<input id="auth_check" type="hidden" value="{{ auth()->check() ? '1' : '0' }}" />
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-6 lg:flex-row">
|
||||
<!-- Cover -->
|
||||
<div class="w-[100px] md:w-[150px] mr-4 float-left hidden md:block">
|
||||
<img alt="{{ $episode->title }}" loading="lazy" width="150"
|
||||
class="block relative rounded-xl object-cover object-center aspect-[11/16] z-20"
|
||||
src="{{ $episode->cover_url }}"></img>
|
||||
<div class="hidden shrink-0 md:block">
|
||||
<img
|
||||
alt="{{ $episode->title }}"
|
||||
loading="lazy"
|
||||
width="180"
|
||||
src="{{ $episode->cover_url }}"
|
||||
class="aspect-[11/16] w-[140px] rounded-2xl object-cover shadow-lg ring-1 ring-black/5 dark:ring-white/10" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="relative">
|
||||
<input id="e_id" type="hidden" value="{{ $episode->id }}" />
|
||||
<div class="flex flex-col justify-between xl:flex-row">
|
||||
<div>
|
||||
<!-- Title -->
|
||||
<h1 class="text-3xl font-bold text-rose-600">
|
||||
<a class="text-rose-600 break-words hover:underline hover:underline-offset-4"
|
||||
href="{{ route('hentai.index', ['title' => $episode->hentai->slug]) }}">{{ $episode->title }}</a>
|
||||
- {{ $episode->episode }}
|
||||
</h1>
|
||||
<div>
|
||||
<h2 class="inline leading-tight text-gray-800 dark:text-white">{{ $episode->title_jpn }}</h2>
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
|
||||
<a data-te-toggle="tooltip"
|
||||
title="Uploaded {{ $episode->created_at->diffForHumans(['parts' => 2]) }}"
|
||||
class="pl-1 leading-tight text-gray-800 text-l dark:text-white">
|
||||
<p class="inline">
|
||||
| <i class="fa-solid fa-upload"></i> {{ $episode->created_at->format('Y-m-d') }} |
|
||||
</p>
|
||||
<!-- Title + Metadata -->
|
||||
<div class="min-w-0">
|
||||
<h1
|
||||
class="break-words text-2xl font-black tracking-tight text-gray-900 dark:text-white md:text-4xl">
|
||||
@if ($streamPage)
|
||||
<a
|
||||
href="{{ route('hentai.index', ['title' => $episode->hentai->slug]) }}"
|
||||
class="bg-gradient-to-r from-rose-500 to-pink-500 bg-clip-text text-transparent transition hover:opacity-80">
|
||||
{{ "$episode->title - $episode->episode" }}
|
||||
</a>
|
||||
<a data-te-toggle="tooltip"
|
||||
title="Released {{ \Carbon\Carbon::parse($episode->release_date)->diffForHumans(['parts' => 2]) }}"
|
||||
class="pl-1 leading-tight text-gray-800 text-l dark:text-white">
|
||||
<p class="inline">
|
||||
<i class="fa-regular fa-calendar"></i> {{ $episode->release_date }}
|
||||
|
|
||||
@else
|
||||
<span
|
||||
class="bg-gradient-to-r from-rose-500 to-pink-500 bg-clip-text text-transparent transition">
|
||||
{{ $episode->title }}
|
||||
</span>
|
||||
@endif
|
||||
</h1>
|
||||
|
||||
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 md:text-base">
|
||||
{{ $episode->title_jpn }}
|
||||
</p>
|
||||
</a>
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
|
||||
class="leading-tight text-gray-800 text-l dark:text-white hover:underline hover:underline-offset-4">
|
||||
|
||||
<!-- Meta Pills -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-gray-700 dark:bg-white/5 dark:text-gray-300">
|
||||
<i class="fa-solid fa-upload text-xs"></i>
|
||||
{{ $episode->created_at->format('Y-m-d') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-gray-700 dark:bg-white/5 dark:text-gray-300">
|
||||
<i class="fa-regular fa-calendar text-xs"></i>
|
||||
{{ $episode->release_date }}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'studios[0]' => $episode->studio->slug]) }}"
|
||||
class="inline-flex items-center rounded-full bg-rose-100 px-3 py-1 font-medium text-rose-700 transition hover:bg-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20">
|
||||
{{ $episode->studio->name }}
|
||||
</a>
|
||||
<a id="av1-unsupported" data-te-toggle="tooltip"
|
||||
title="For 1080p and 4k streams we are using the new AV1 codec. Edge users on Windows have to install the AV1 extension pack from the Microsoft Store."
|
||||
class="hidden leading-tight text-red-800 cursor-pointer text-l dark:text-red-500">
|
||||
|
||||
<a
|
||||
id="av1-unsupported"
|
||||
data-te-toggle="tooltip"
|
||||
title="For 1080p and 4k streams we are using the new AV1 codec."
|
||||
class="hidden rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-700 dark:bg-red-500/10 dark:text-red-400">
|
||||
AV1 Unsupported
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- View Count and Misc Buttons -->
|
||||
<div class="float-right">
|
||||
<div class="grid">
|
||||
<div class="flex gap-x-4">
|
||||
@if($streamPage)
|
||||
<!-- Stats + Actions -->
|
||||
<div class="flex flex-col gap-3 xl:items-end min-w-[330px]">
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
|
||||
<div
|
||||
class="inline-flex font-bold items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
{{ $episode->viewCountFormatted() }}
|
||||
</div>
|
||||
|
||||
@auth
|
||||
@livewire('view-count', ['episode' => $episode])
|
||||
@livewire('like-button', ['episode' => $episode])
|
||||
@endauth
|
||||
|
||||
@guest
|
||||
<div>
|
||||
<a class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200">
|
||||
<i class="pr-0.5 fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }}
|
||||
</a>
|
||||
</div>
|
||||
<div data-te-toggle="tooltip" title="Please login to like the episode"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200">
|
||||
<i class="fa-regular fa-heart pr-[4px]"></i> {{ $episode->likeCount() }}
|
||||
<div
|
||||
data-te-toggle="tooltip"
|
||||
title="Please login to like the episode"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
{{ $episode->likeCount() }}
|
||||
</div>
|
||||
@endguest
|
||||
|
||||
@php $commentcount = $episode->commentCount(); @endphp
|
||||
@if ($commentcount > 0)
|
||||
<a href="#comments"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200">
|
||||
<i class="pr-0.5 fa-regular fa-comment"></i> {{ $commentcount }}
|
||||
|
||||
<a
|
||||
href="#comments"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-gray-700 transition hover:bg-gray-200 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-regular fa-comment"></i>
|
||||
{{ $commentcount }}
|
||||
</a>
|
||||
@else
|
||||
<a href="#comments" data-te-toggle="tooltip" title="Be the first one to comment!"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap dark:text-gray-200">
|
||||
<i class="pr-0.5 fa-regular fa-comment"></i> {{ $commentcount }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@if(!$episode->dmca_takedown)
|
||||
<a data-te-toggle="modal" data-te-target="#modalDownload" id="reloadCaptchaModal"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200">
|
||||
<i class="fa-solid fa-download pr-[4px]"></i> {{ __('stream.download') }}
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalDownload"
|
||||
id="reloadCaptchaModal"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
{{ __('stream.download') }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<a data-te-toggle="modal" data-te-target="#modalShare"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200">
|
||||
<i class="fa-solid fa-share pr-[4px]"></i> {{ __('stream.share') }}
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalShare"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-solid fa-share"></i>
|
||||
{{ __('stream.share') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
<div>
|
||||
<a data-te-toggle="modal" data-te-target="#modalAddToPlaylist"
|
||||
class="text-xl leading-tight text-gray-800 whitespace-nowrap cursor-pointer dark:text-gray-200">
|
||||
<i class="fa-solid fa-square-plus pr-[6px]"></i> {{ __('playlist.playlist') }}
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalAddToPlaylist"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-solid fa-square-plus"></i>
|
||||
{{ __('playlist.playlist') }}
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mt-2 mb-2 border-gray-400/40">
|
||||
<p class="text-lg font-bold leading-normal text-rose-600">
|
||||
{{ __('stream.description') }}
|
||||
</p>
|
||||
<p class="text-gray-800 dark:text-gray-200 leading-tight min-h-[50%]">
|
||||
{{ $episode->description }}
|
||||
</p>
|
||||
<hr class="mt-2 mb-1 border-gray-400/40">
|
||||
<p class="text-lg font-bold leading-normal text-rose-600">
|
||||
{{ __('stream.genres') }}
|
||||
</p>
|
||||
<ul class="list-none text-center" style="overflow: hidden;">
|
||||
<a class="text-gray-400">
|
||||
|
|
||||
|
||||
@if(auth()->check() && (auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR) || auth()->user()->hasRole(\App\Enums\UserRole::MODERATOR)))
|
||||
<div class="flex flex-wrap flex-row-reverse gap-2">
|
||||
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalAddSubtitles"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add Subtitles
|
||||
</a>
|
||||
@foreach ($episode->tags->sortBy('slug') as $tag)
|
||||
<li class="inline-block p-1">
|
||||
@if ($tag->slug == 'uncensored' || $tag->slug == 'vanilla' || $tag->slug == '4k')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-green-500 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-green-700 focus:bg-green-700 active:bg-green-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
@elseif($tag->slug == 'censored')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-yellow-600 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-yellow-700 focus:bg-yellow-700 active:bg-yellow-900 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2">
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
@elseif(
|
||||
$tag->slug == 'gore' ||
|
||||
$tag->slug == 'horror' ||
|
||||
$tag->slug == 'scat' ||
|
||||
$tag->slug == 'ntr' ||
|
||||
$tag->slug == 'rape')
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest text-red-600 uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:hover:text-white hover:text-white hover:bg-red-700 focus:bg-red-700 active:bg-red-900 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ $tag->name }}
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="block relative items-center px-2 py-2 mt-1 text-xs font-semibold tracking-widest uppercase rounded-md border border-transparent transition duration-150 ease-in-out dark:focus:ring-offset-gray-800 dark:text-white hover:text-white hover:bg-rose-700 focus:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2">
|
||||
{{ $tag->name }}
|
||||
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalUploadEpisode"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add Episode
|
||||
</a>
|
||||
@endif
|
||||
</li>
|
||||
<a class="text-gray-400">
|
||||
|
|
||||
|
||||
<a
|
||||
data-te-toggle="modal"
|
||||
data-te-target="#modalEditEpisode"
|
||||
class="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
Edit Episode
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h2 class="mb-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ __('stream.description') }}
|
||||
</h2>
|
||||
|
||||
<p class="leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
{{ $episode->description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ __('stream.genres') }}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@foreach ($episode->tags->sortBy('slug') as $tag)
|
||||
|
||||
@php
|
||||
$classes = 'bg-gray-100 text-gray-700 hover:bg-rose-600 hover:text-white dark:bg-white/5 dark:text-gray-300 dark:hover:bg-rose-600';
|
||||
|
||||
if (in_array($tag->slug, ['uncensored', 'vanilla', '4k'])) {
|
||||
$classes = 'bg-green-100 text-green-700 hover:bg-green-600 hover:text-white dark:hover:text-white dark:bg-green-500/10 dark:text-green-400 dark:hover:bg-green-600';
|
||||
}
|
||||
|
||||
if ($tag->slug === 'censored') {
|
||||
$classes = 'bg-yellow-100 text-yellow-700 hover:bg-yellow-500 hover:text-white dark:hover:text-white dark:bg-yellow-500/10 dark:text-yellow-400 dark:hover:bg-yellow-500';
|
||||
}
|
||||
|
||||
if (in_array($tag->slug, ['gore', 'horror', 'scat', 'ntr', 'rape'])) {
|
||||
$classes = 'bg-red-100 text-red-700 hover:bg-red-600 hover:text-white dark:hover:text-white dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-600';
|
||||
}
|
||||
@endphp
|
||||
|
||||
<a
|
||||
href="{{ route('hentai.search', ['order' => 'recently-uploaded', 'tags[0]' => $tag->slug]) }}"
|
||||
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-xs font-bold uppercase tracking-wide transition {{ $classes }}">
|
||||
|
||||
@if(in_array($tag->slug, ['gore', 'horror', 'scat', 'ntr', 'rape']))
|
||||
<i class="fa-solid fa-triangle-exclamation text-[10px]"></i>
|
||||
@endif
|
||||
|
||||
{{ $tag->name }}
|
||||
</a>
|
||||
|
||||
@endforeach
|
||||
</ul>
|
||||
<hr class="mt-2 mb-1 border-gray-400/40">
|
||||
<div class="inline-block pt-5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
@if($streamPage)
|
||||
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
@include('stream.partials.gallery')
|
||||
</div>
|
||||
@endisset
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,6 +8,42 @@
|
||||
@endif
|
||||
<canvas id="ambientVideo" class="decoy"></canvas>
|
||||
<div class="relative w-full aspect-[16/9]">
|
||||
<video id="player" playsinline controls crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
<div class="player-switcher" id="player-switcher">
|
||||
<span class="player-switcher__label">Player</span>
|
||||
<button type="button" class="player-switcher__btn" id="plyr-toggle-btn"
|
||||
onclick="window.setPlayerPreference('plyr')" title="Switch to Plyr player">
|
||||
<span class="player-switcher__dot"></span> Plyr
|
||||
</button>
|
||||
<button type="button" class="player-switcher__btn player-switcher__btn--active" id="hstream-toggle-btn"
|
||||
onclick="window.setPlayerPreference('hstream')" title="Switch to HStream player">
|
||||
<span class="player-switcher__dot"></span> HStream
|
||||
</button>
|
||||
</div>
|
||||
<video id="player" playsinline crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var pref = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
var hstreamBtn = document.getElementById('hstream-toggle-btn');
|
||||
var plyrBtn = document.getElementById('plyr-toggle-btn');
|
||||
if (pref === 'plyr') {
|
||||
hstreamBtn.classList.remove('player-switcher__btn--active');
|
||||
plyrBtn.classList.add('player-switcher__btn--active');
|
||||
}
|
||||
|
||||
var switcher = document.getElementById('player-switcher');
|
||||
var container = switcher.parentElement;
|
||||
var hideTimer = null;
|
||||
|
||||
function showSwitcher() {
|
||||
switcher.classList.add('player-switcher--visible');
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(function () {
|
||||
switcher.classList.remove('player-switcher--visible');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
container.addEventListener('pointerdown', showSwitcher);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -45,7 +45,7 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
|
||||
|
||||
// Episode
|
||||
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
|
||||
Route::post('/admin/episode/edit', [EpisodeController::class, 'update'])->name('admin.edit');
|
||||
|
||||
|
||||
// Get Tags used for Upload Form
|
||||
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
|
||||
@@ -66,4 +66,7 @@ Route::group(['middleware' => ['auth', 'auth.moderator']], function () {
|
||||
// Get Tags for editing Episode
|
||||
Route::get('/admin/tags/{episode_id}', [AdminApiController::class, 'getEpisodeTags'])->name('admin.tags.episode');
|
||||
Route::get('/admin/studio/{episode_id}', [AdminApiController::class, 'getEpisodeStudio'])->name('admin.studio.episode');
|
||||
|
||||
// Edit Episode
|
||||
Route::post('/admin/episode/edit', [EpisodeController::class, 'update'])->name('admin.episode.edit');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user