Compare commits

...

75 Commits

Author SHA1 Message Date
823a284fbc Fix PHP 8.4 deprecation warning 2026-01-11 16:39:14 +01:00
67e601d0c4 Add ability to properly set locale (session/account) 2026-01-11 16:33:13 +01:00
7e4ebd91ad Remove vluzrmos/language-detector package 2026-01-11 16:31:16 +01:00
4dc5dee2b9 Update dependencies 2026-01-11 15:46:33 +01:00
5310908b0c Update login button on mobile 2026-01-11 00:21:05 +01:00
4b05b3db6d Fix cache not flushed after comment delete by admin 2026-01-10 23:24:31 +01:00
df47a926e4 Fix comment mass delete 2026-01-10 23:21:29 +01:00
1e9e95f35f Fix admin comment moderation 2026-01-10 22:35:35 +01:00
2aa76baafd Fix account deletion anonymizing comments 2026-01-10 22:35:21 +01:00
aa50bb1f72 Merge pull request 'Replace Comment System' (#4) from comment-system into main
Reviewed-on: #4
2026-01-10 21:16:55 +00:00
dfedf4058e Fix rate limit and make it more strict (1 message in 5 minutes) 2026-01-10 22:15:50 +01:00
268e3eb4c2 Add Notification for comments 2026-01-10 22:00:09 +01:00
ab61574956 Fix comment depth chain check 2026-01-10 21:59:53 +01:00
81038b6c26 Add id to comment, so it can autoscroll to that notification 2026-01-10 21:59:08 +01:00
e949ba955a Add rate limiter to comment system 2026-01-10 21:04:26 +01:00
819e2fde27 Misc changes 2026-01-10 20:33:35 +01:00
3259e2197b Update design comments home page 2026-01-10 19:45:19 +01:00
b133db0573 Add likes to comments 2026-01-10 19:41:23 +01:00
41c34e6d89 Fix style 2026-01-10 19:15:32 +01:00
db6da608aa Add comments to home page 2026-01-10 19:11:28 +01:00
13b70fdf23 Misc changes 2026-01-10 18:55:53 +01:00
cfd6af59fb Add Profile Comment Search (Livewire) 2026-01-10 18:55:47 +01:00
7810cd53fb Add comments to Hentai 2026-01-10 18:54:48 +01:00
871028930b Migrate existing comments 2026-01-10 16:41:06 +01:00
6ce0255764 Remove ring offset 2026-01-10 15:45:52 +01:00
e136e8e1b6 Refresh on delete 2026-01-10 15:45:41 +01:00
a3b66b483b Add admin and donator badge 2026-01-10 15:34:05 +01:00
4c2a6024d7 Add dark mode 2026-01-10 15:27:37 +01:00
5f575024e2 Add Livewire comment system 2026-01-10 15:02:14 +01:00
67f5d0db8b Remove laravelista/comments 2026-01-10 14:06:00 +01:00
571bf4584c Remove view_count from meilisearch 2026-01-10 12:27:54 +01:00
d7dc96e11c Don't trigger update on view_count increase 2026-01-10 12:27:24 +01:00
58426b6e4e Add studio filter on download page closes #1 2026-01-09 22:51:15 +01:00
53b600daea Fix certain livewire components not working 2026-01-09 22:32:16 +01:00
224cdbcdc5 Save mute state of player - fixes #2 2026-01-09 22:28:53 +01:00
972d3d0aa4 Add zhentube.com to footer 2026-01-09 22:20:02 +01:00
8f7f012c14 Merge pull request 'Replace Auth System' (#3) from auth-redo into main
Reviewed-on: #3
2026-01-09 15:11:36 +00:00
c0b068de58 Misc changes 2026-01-09 13:01:53 +01:00
51c67bb797 Improve Migrations & Fix Discord Avatars 2026-01-09 10:45:41 +01:00
3d78f9e524 Optionally update discord avatar on login 2026-01-08 22:17:00 +01:00
2d28a37463 Don't set password on new account with oauth 2026-01-08 20:03:24 +01:00
ac853920ee Fix delete account function & delete modal 2026-01-08 19:28:44 +01:00
fb3722036a Add ability to set custom avatar 2026-01-08 18:47:31 +01:00
ab4e7c7999 Update Mail Design (Password Reset) 2026-01-08 17:21:03 +01:00
8f99718058 Allow changing email, username and password 2026-01-08 16:14:35 +01:00
2029af334c Fix Signup redirect 2026-01-08 16:13:54 +01:00
b1c48830c4 Remove nickname 2026-01-08 16:13:43 +01:00
e100f3bf23 Remove Public Profile Page (because usernames are not unique) 2026-01-07 20:28:40 +01:00
c13d443696 Add discord patreon check 2026-01-07 19:04:51 +01:00
8e7a56f559 Add discord oauth 2026-01-07 18:17:46 +01:00
30777a6968 Login / Register Design 2026-01-07 17:03:57 +01:00
256af435ad Add Auth System (Breeze) 2026-01-07 16:04:23 +01:00
e972f8db41 Rename column names 2026-01-07 12:54:10 +01:00
98d36d6018 Fix database structure 2026-01-07 12:41:11 +01:00
7eea8285ca Remove old breeze auth controllers 2026-01-07 12:10:49 +01:00
9e8efbbe05 Remove jakyeru/larascord 2026-01-07 12:02:02 +01:00
5461606857 DMCA 2025-12-19 22:13:50 +01:00
9ca2f73714 Admin: Add comments overview for moderation 2025-10-29 15:59:43 +01:00
59d63abd79 Admin: Add ability to delete all coments from user 2025-10-28 16:20:40 +01:00
efb3e4197b Use cached values for view and like count in nav search 2025-10-26 21:25:50 +01:00
735dd693ca [Expiremental] Add ability to disable blur effects 2025-10-24 23:15:52 +02:00
36f0126a21 Only display last 28 days on stats page (excluding current day) 2025-10-23 21:13:07 +02:00
50d8704560 Fix mobile home page offset 2025-10-23 19:33:30 +02:00
7e382ffe1d Redesign nav search 2025-10-23 15:39:07 +02:00
6a25fd2700 Use meilisearch in nav search 2025-10-23 15:30:32 +02:00
71bcf277f6 Add type icons to downloads search page 2025-10-15 19:33:37 +02:00
6c44d83e6b Add type filter to download search page 2025-10-15 18:50:49 +02:00
444feac1e0 Use redirect for random button to save database queries 2025-10-09 12:55:24 +02:00
c034c94db5 Add padding to comments on home page 2025-10-09 12:38:54 +02:00
ca52584da9 Cache random hentai row for 5 minutes 2025-10-09 12:38:17 +02:00
dca4924e9a Add v2 re-release discord webhook 2025-10-08 20:01:06 +02:00
9ad7c7afc2 Fix incorrect cache key 2025-10-08 19:50:55 +02:00
6c8d34b030 Fix vite build 2025-10-08 19:38:54 +02:00
35a0d61437 Add censored discord notification 2025-10-08 18:32:08 +02:00
725a441d9e Add discord notification for v2 releases 2025-10-08 17:41:10 +02:00
153 changed files with 6357 additions and 3807 deletions

View File

@@ -57,3 +57,8 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
SCOUT_QUEUE=true
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey

View File

@@ -28,6 +28,10 @@ apt install php8.3-fpm
apt install mariadb-server
sudo mysql_secure_installation
# Install Meilisearch
echo "deb [trusted=yes] https://apt.fury.io/meilisearch/ /" | sudo tee /etc/apt/sources.list.d/fury.list
sudo apt update && sudo apt install meilisearch
# Clone Repo
cd /var/www
git clone https://gitea.hstream.moe/w33b/hstream.git

View File

@@ -30,9 +30,9 @@ class AutoStats extends Command
*/
public function handle()
{
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->forceDelete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->forceDelete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->forceDelete();
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->delete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->delete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->delete();
$this->comment('Automated Purge Stats Complete');
}

View File

@@ -35,6 +35,6 @@ class ResetUserDownloads extends Command
// Clear old downloads which have expired
UserDownload::where('created_at', '<=', Carbon::now()->subHour(6))
->forceDelete();
->delete();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Helpers;
use App\Models\Comment;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\PopularMonthly;
@@ -126,7 +127,7 @@ class CacheHelper
public static function getLatestComments()
{
return Cache::remember("latest_comments", now()->addMinutes(60), function () {
return DB::table('comments')->latest()->take(10)->get();
return Comment::latest()->take(10)->get();
});
}
}

View File

@@ -41,7 +41,7 @@ class AlertController extends Controller
*/
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse
{
Alert::where('id', $alert_id)->forceDelete();
Alert::where('id', $alert_id)->delete();
cache()->forget('alerts');

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
class CommentsController extends Controller
{
/**
* Display Comments Page.
*/
public function index(): \Illuminate\View\View
{
return view('admin.comments.index');
}
}

View File

@@ -42,7 +42,11 @@ class EpisodeController extends Controller
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert
DiscordReleaseNotification::dispatch($episode->slug, 'release');
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($referenceEpisode->title." - ".$episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episode->slug, 'release');
}
cache()->flush();
@@ -76,6 +80,10 @@ class EpisodeController extends Controller
DiscordReleaseNotification::dispatch($episode->slug, 'updateUHD');
}
if ($request->has('v2')) {
DiscordReleaseNotification::dispatch($episode->slug, 'v2');
}
cache()->flush();
return to_route('hentai.index', [

View File

@@ -72,9 +72,13 @@ class ReleaseController extends Controller
$releasedEpisodes[] = $episode->slug;
}
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored');
} else {
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
}
}
cache()->flush();

View File

@@ -105,7 +105,7 @@ class SiteBackgroundController extends Controller
DB::beginTransaction();
$bg = SiteBackground::where('id', $id)->firstOrFail();
$bg->forceDelete();
$bg->delete();
$resolutions = [1440, 1080, 720, 640];
try {

View File

@@ -38,7 +38,7 @@ class SubtitleController extends Controller
// Clear everything
foreach($episode->subtitles as $sub) {
$sub->forceDelete();
$sub->delete();
}
if (! $request->input('subtitles')) {

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Models\Hentai;
use App\Models\PopularMonthly;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use App\Http\Controllers\Controller;
@@ -44,8 +45,10 @@ class HentaiApiController extends Controller
public function getMonthlyViews()
{
// Cache for 60 minutes
$data = Cache::remember('api_hentai_list', now()->addMinutes(60), function () {
$data = Cache::remember('api_monthly_views', now()->addMinutes(60), function () {
return PopularMonthly::selectRaw('DATE(created_at) as date, COUNT(*) as count')
->whereDate('created_at', '<', Carbon::today())
->whereDate('created_at', '>=', Carbon::today()->subDays(28))
->groupBy('date')
->orderBy('date', 'asc')
->get();

View File

@@ -30,7 +30,7 @@ class StreamApiController extends Controller
'poster' => $episode->gallery()->first()->image_url,
'interpolated' => $episode->interpolated,
'interpolated_uhd' => $episode->interpolated_uhd,
'stream_url' => $episode->url,
'stream_url' => $episode->dmca_takedown ? 'stuff/dmca' : $episode->url,
'stream_domains' => config('hstream.stream_domain'),
'asia_stream_domains' => config('hstream.asia_stream_domain'),
'extra_subtitles' => $subtitles

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -29,7 +28,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
/**

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -36,6 +35,6 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Facades\Socialite;
class DiscordAuthController extends Controller
{
/**
* Redirect to Discord
*/
public function redirect(): RedirectResponse
{
return Socialite::driver('discord')->redirect();
}
/**
* Callback received from Discord
*/
public function callback(): RedirectResponse
{
$discordUser = Socialite::driver('discord')->user();
$user = User::where('discord_id', $discordUser->id)->first();
if (!$user) {
// link by email if it already exists
$user = User::where('email', $discordUser->email)->first();
if ($user) {
$user->update([
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
]);
} else {
// Create new user
$user = User::create([
'name' => $discordUser->name,
'email' => $discordUser->email,
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
'password' => null,
]);
}
}
$this->checkDiscordAvatar($discordUser, $user);
$this->checkDiscordRoles($user);
Auth::login($user, true);
return redirect()->route('home.index');
}
/**
* Check if discord avatar changed
*/
private function checkDiscordAvatar(\Laravel\Socialite\Contracts\User $socialiteUser, User $user): void
{
if ($socialiteUser->avatar != $user->discord_avatar) {
$user->update(['discord_avatar' => $socialiteUser->avatar]);
}
}
/**
* Check Discord Roles if user is Patreon member
*/
private function checkDiscordRoles(User $user): void
{
// Should not ever happen
if (!$user->discord_id) {
return;
}
$guildId = config('discord.guild_id');
$response = Http::withToken(config('discord.discord_bot_token'), 'Bot')
->timeout(5)
->get("https://discord.com/api/v10/guilds/{$guildId}/members/{$user->discord_id}");
// User is not in the guild
if ($response->status() === 404) {
$user->update([
'is_patreon' => false,
]);
return;
}
// Something else failed
if ($response->failed()) {
Log::warning('Discord role check failed', [
'user_id' => $user->id,
'discord_id' => $user->discord_id,
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$discordRoles = $response->json('roles', []);
$patreonRoles = config('discord.patreon_roles', []);
$isPatreon = false;
foreach($patreonRoles as $patreonRole)
{
if (in_array($patreonRole, $discordRoles, true)) {
$isPatreon = true;
break;
}
}
// Only update if something actually changed
if ($user->is_patreon !== $isPatreon) {
$user->update([
'is_patreon' => $isPatreon,
]);
}
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -15,7 +14,7 @@ class EmailVerificationNotificationController extends Controller
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME);
return redirect()->intended(route('home.index', absolute: false));
}
$request->user()->sendEmailVerificationNotification();

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -16,7 +15,7 @@ class EmailVerificationPromptController extends Controller
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME)
? redirect()->intended(route('home.index', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -40,7 +41,7 @@ class NewPasswordController extends Controller
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
@@ -56,6 +57,6 @@ class NewPasswordController extends Controller
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
->withErrors(['email' => __($status)]);
}
}

View File

@@ -15,6 +15,20 @@ class PasswordController extends Controller
*/
public function update(Request $request): RedirectResponse
{
// If user logged in with Discord and has not yet a password, allow to set password
if ($request->user()->discord_id && is_null($request->user()->password))
{
$validated = $request->validateWithBag('updatePassword', [
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],

View File

@@ -39,6 +39,6 @@ class PasswordResetLinkController extends Controller
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
->withErrors(['email' => __($status)]);
}
}

View File

@@ -4,25 +4,15 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
@@ -32,7 +22,7 @@ class RegisteredUserController extends Controller
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
@@ -46,6 +36,6 @@ class RegisteredUserController extends Controller
Auth::login($user);
return redirect(RouteServiceProvider::HOME);
return redirect(route('home.index', absolute: false));
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
@@ -16,13 +15,13 @@ class VerifyEmailController extends Controller
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
return redirect()->intended(route('home.index', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
return redirect()->intended(route('home.index', absolute: false).'?verified=1');
}
}

View File

@@ -52,6 +52,22 @@ class HomeController extends Controller
return view('auth.banned');
}
/**
* Redirects to a random Hentai episode
* Done due to performance reasons
*/
public function random(): \Illuminate\Http\RedirectResponse
{
$random = Episode::inRandomOrder()
->limit(1)
->pluck('slug')
->first();
return redirect()->route('hentai.index', [
'title' => $random,
]);
}
/**
* Display Search Page.
*/
@@ -95,11 +111,13 @@ class HomeController extends Controller
*/
public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse
{
if(! in_array($request->language, config('lang-detector.languages'))) {
return redirect()->back();
}
abort_unless(in_array($request->language, config('app.supported_locales'), true), 404);
Cookie::queue(Cookie::forever('locale', $request->language));
session(['locale' => $request->language]);
if (Auth::check()) {
Auth::user()->update(['locale' => $request->language]);
}
return redirect()->back();
}

View File

@@ -32,7 +32,7 @@ class NotificationController extends Controller
->where('id', $request->input('id'))
->firstOrFail();
$notification->forceDelete();
$notification->delete();
return redirect()->back();
}

View File

@@ -105,13 +105,11 @@ class PlaylistController extends Controller
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)->where('id', $playlist_id)->firstOrFail();
$playlist = Playlist::where('user_id', $user->id)
->where('id', $playlist_id)
->firstOrFail();
// Delete Playlist Episodes
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
// Delete Playlist
$playlist->forceDelete();
$playlist->delete();
return to_route('profile.playlists');
}
@@ -128,8 +126,14 @@ class PlaylistController extends Controller
], 404);
}
$playlist = Playlist::where('user_id', $request->user()->id)->where('id', (int) $request->input('playlist'))->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', (int) $request->input('episode'))->forceDelete();
$playlist = Playlist::where('user_id', $request->user()->id)
->where('id', (int) $request->input('playlist'))
->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)
->where('episode_id', (int) $request->input('episode'))
->delete();
$this->playlistService->reorderPositions($playlist);
return response()->json([

View File

@@ -3,12 +3,18 @@
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Models\User;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Laravel\Facades\Image;
use Conner\Tagging\Model\Tag;
@@ -17,7 +23,7 @@ class ProfileController extends Controller
/**
* Display the user page.
*/
public function index(Request $request): \Illuminate\View\View
public function index(Request $request): View
{
return view('profile.index', [
'user' => $request->user(),
@@ -27,7 +33,7 @@ class ProfileController extends Controller
/**
* Display the user's settings form.
*/
public function settings(Request $request): \Illuminate\View\View
public function settings(Request $request): View
{
$example = Episode::where('title', 'Succubus Yondara Gibo ga Kita!?')->first();
@@ -37,10 +43,33 @@ class ProfileController extends Controller
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
// Fill everything except the image
$user->fill($request->safe()->except('image'));
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
if ($request->hasFile('image')) {
$this->storeAvatar($request->file('image'), $user);
}
$user->save();
return Redirect::route('profile.settings')->with('status', 'profile-updated');
}
/**
* Display the user's watched page.
*/
public function watched(Request $request): \Illuminate\View\View
public function watched(Request $request): View
{
return view('profile.watched', [
'user' => $request->user(),
@@ -50,17 +79,17 @@ class ProfileController extends Controller
/**
* Display the user's comments page.
*/
public function comments(Request $request): \Illuminate\View\View
public function comments(Request $request): View
{
return view('profile.comments', [
'user' => $request->user(),
'user' => $request->user(),
]);
}
/**
* Display the user's likes page.
*/
public function likes(Request $request): \Illuminate\View\View
public function likes(Request $request): View
{
return view('profile.likes', [
'user' => $request->user(),
@@ -70,7 +99,7 @@ class ProfileController extends Controller
/**
* Update user settings.
*/
public function saveSettings(Request $request): \Illuminate\Http\RedirectResponse
public function saveSettings(Request $request): RedirectResponse
{
$user = $request->user();
$user->search_design = $request->input('searchDesign') == 'thumbnail';
@@ -84,7 +113,7 @@ class ProfileController extends Controller
/**
* Update user tag blacklist.
*/
public function saveBlacklist(Request $request): \Illuminate\Http\RedirectResponse
public function saveBlacklist(Request $request): RedirectResponse
{
$user = $request->user();
$tags = json_decode($request->input('tags'));
@@ -112,19 +141,60 @@ class ProfileController extends Controller
*/
public function destroy(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
// Verify password if user has password
if (!is_null($user->password)) {
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
}
// Update comments to deleted user
DB::table('comments')->where('user_id', '=', $user->id)->update(['user_id' => 1]);
// Delete Profile Picture
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return Redirect::to('/');
}
/**
* Store custom user avatar.
*/
protected function storeAvatar(\Illuminate\Http\UploadedFile $file, User $user): void
{
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/avatars")) {
Storage::disk('public')->makeDirectory("/images/avatars");
}
// Delete old avatar if it exists
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$filename = "images/avatars/{$user->id}.webp";
$image = Image::read($file->getRealPath())
->cover(128, 128)
->toWebp(quality: 85);
Storage::disk('public')->put($filename, $image);
$user->avatar = $filename;
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Watched;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
/**
* Display User Page.
*/
public function index(string $username): \Illuminate\View\View
{
$user = User::where('username', $username)
->select('id', 'username', 'global_name', 'avatar', 'created_at', 'is_patreon')
->firstOrFail();
return view('user.index', [
'user' => $user,
]);
}
/**
* Delete User.
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$user = User::where('id', $request->user()->id)->firstOrFail();
// Delete Playlist
$playlists = Playlist::where('user_id', $user->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $user->id)->update(['commenter_id' => 1]);
$user->forceDelete();
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return redirect('/');
}
}

View File

@@ -37,6 +37,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\IsBanned::class,
\App\Http\Middleware\SetLocale::class,
],
'api' => [

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 1. Logged-in user preference
if (Auth::check() && Auth::user()->locale) {
App::setLocale(Auth::user()->locale);
return $next($request);
}
// 2. Session (guest or user override)
if (session()->has('locale') && in_array($request->language, config('app.supported_locales'), true)) {
App::setLocale(session('locale'));
return $next($request);
}
// 3. Browser language
$locale = $request->getPreferredLanguage(config('app.supported_locales'));
if ($locale) {
App::setLocale($locale);
}
return $next($request);
}
}

View File

@@ -22,7 +22,7 @@ class LoginRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -80,6 +80,6 @@ class LoginRequest extends FormRequest
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -11,13 +11,26 @@ class ProfileUpdateRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'name' => ['required', 'string', 'max:255'],
'image' => [
'nullable',
'image',
'mimes:jpg,png,jpeg,webp,gif',
'max:8192'
],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -32,19 +32,29 @@ class DiscordReleaseNotification implements ShouldQueue
*/
public function handle(): void
{
// Wait 2s to avoid Discord API Rate limit
sleep(2);
if ($this->messageType == 'release') {
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
}
if ($this->messageType == 'update') {
DiscordAlert::to('update')->message("<@&1283518462584426598> (´• ω •`)ノ Added **48fps** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
}
if ($this->messageType == 'updateUHD') {
DiscordAlert::to('update')->message("<@&1326860920902778963> (´• ω •`)ノ Added **48fps 4k** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
switch($this->messageType)
{
case 'release':
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
case 'release-censored':
# Because Discord TOS
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release: ".$this->slug." - *No link here because of* :pLoli:");
break;
case 'update':
# 1080p 48fps added
DiscordAlert::to('update')->message("<@&1283518462584426598> (´• ω •`)ノ Added **48fps** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
case 'updateUHD':
# 4k 48fps added
DiscordAlert::to('update')->message("<@&1326860920902778963> (´• ω •`)ノ Added **48fps 4k** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
case 'v2':
# v2 re-release
DiscordAlert::to('rerelease')->message("<@&1425505303075754035> (´• ω •`)ノ **v2 Re-**Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Livewire;
use App\Models\Comment;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Support\Facades\DB;
class AdminCommentSearch extends Component
{
use WithPagination;
public $search = '';
public $userSearch = '';
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingUserSearch(): void
{
$this->resetPage();
}
public function deleteComment($commentId)
{
$comment = Comment::where('id', (int) $commentId)->firstOrFail();
$comment->delete();
cache()->flush();
}
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
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Comment;
use App\Models\User;
use Livewire\Component;
@@ -24,15 +25,23 @@ class AdminUserSearch extends Component
#[Url(history: true)]
public $banned = [];
public function deleteUserComments(int $userID)
{
$user = User::where('id', $userID)
->firstOrFail();
Comment::where('user_id', $user->id)
->delete();
cache()->flush();
}
public function render()
{
$users = User::when($this->filtered !== [], fn ($query) => $query->where('id', '>=', 10000))
->when($this->patreon !== [], fn ($query) => $query->where('is_patreon', 1))
->when($this->banned !== [], fn ($query) => $query->where('is_banned', 1))
->when($this->search !== '', fn ($query) => $query->where(function($query) {
$query->where('username', 'like', '%'.$this->search.'%')
->orWhere('global_name', 'like', '%'.$this->search.'%');
}))
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->paginate(20);
return view('livewire.admin-user-search', [

166
app/Livewire/Comment.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
namespace App\Livewire;
use App\Models\User;
use App\Models\Episode;
use App\Notifications\CommentNotification;
use Livewire\Component;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Maize\Markable\Models\Like;
class Comment extends Component
{
use AuthorizesRequests;
public $comment;
public $isReplying = false;
public $likeCount = 0;
public $liked = false;
public $replyState = [
'body' => ''
];
public $isEditing = false;
public $editState = [
'body' => ''
];
protected $listeners = [
'refresh' => '$refresh'
];
protected $validationAttributes = [
'replyState.body' => 'reply'
];
public function updatedIsEditing($isEditing)
{
if (! $isEditing) {
return;
}
$this->editState = [
'body' => $this->comment->body
];
}
public function editComment()
{
$this->authorize('update', $this->comment);
$this->comment->update($this->editState);
$this->isEditing = false;
}
public function deleteComment()
{
$this->authorize('destroy', $this->comment);
$this->comment->delete();
$this->dispatch('refresh');
}
public function postReply()
{
if (!($this->comment->depth() < 2)) {
$this->addError('replyState.body', "Too many sub comments.");
return;
}
$user = auth()->user();
$rateLimitKey = "send-comment:{$user->id}";
$rateLimitMinutes = 60 * 5; // 5 minutes
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('replyState.body', "Too many comments. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
$this->validate([
'replyState.body' => 'required'
]);
$reply = $this->comment->children()->make($this->replyState);
$reply->user()->associate($user);
$reply->commentable()->associate($this->comment->commentable);
$reply->save();
// Notify if Episode and if not the same user
if ($reply->commentable_type == Episode::class && $user->id !== $reply->parent->user->id) {
$episode = Episode::where('id', $reply->commentable_id)
->firstOrFail();
$url = route('hentai.index', ['title' => $episode->slug]);
$reply->parent->user->notify(
new CommentNotification(
"{$user->name} replied to your comment.",
Str::limit($reply->body, 50),
"{$url}#comment-{$reply->id}"
)
);
}
$this->replyState = [
'body' => ''
];
$this->isReplying = false;
$this->dispatch('refresh')->self();
}
public function like()
{
if (! Auth::check()) {
return;
}
Like::toggle($this->comment, User::where('id', Auth::user()->id)->firstOrFail());
Cache::forget('commentLikes'.$this->comment->id);
if ($this->liked) {
$this->liked = false;
$this->likeCount--;
return;
}
$this->liked = true;
$this->likeCount++;
}
public function mount()
{
if (Auth::check()) {
$this->likeCount = $this->comment->likeCount();
$this->liked = Like::has($this->comment, User::where('id', Auth::user()->id)->firstOrFail());
}
}
public function render()
{
return view('livewire.comment');
}
}

71
app/Livewire/Comments.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Support\Facades\RateLimiter;
class Comments extends Component
{
use WithPagination;
public $model;
public $newCommentState = [
'body' => ''
];
protected $validationAttributes = [
'newCommentState.body' => 'comment'
];
protected $listeners = [
'refresh' => '$refresh'
];
public function postComment()
{
$this->validate([
'newCommentState.body' => 'required'
]);
$user = auth()->user();
$rateLimitKey = "send-comment:{$user->id}";
$rateLimitMinutes = 60 * 5; // 5 minutes
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('newCommentState.body', "Too many comments. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
$comment = $this->model->comments()->make($this->newCommentState);
$comment->user()->associate($user);
$comment->save();
$this->newCommentState = [
'body' => ''
];
$this->resetPage();
}
public function render()
{
$comments = $this->model
->comments()
->with('user', 'children.user', 'children.children')
->parent()
->latest()
->paginate(50);
return view('livewire.comments', [
'comments' => $comments
]);
}
}

View File

@@ -10,7 +10,6 @@ use Livewire\Component;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class DownloadsFree extends Component
{
@@ -51,7 +50,7 @@ class DownloadsFree extends Component
// Check timestamp
if (Carbon::parse($alreadyDownloaded->created_at)->addHours(6) <= Carbon::now()) {
// Already expired
$alreadyDownloaded->forceDelete();
$alreadyDownloaded->delete();
return;
}

View File

@@ -5,15 +5,41 @@ namespace App\Livewire;
use App\Models\Downloads;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
class DownloadsSearch extends Component
{
use WithPagination;
#[Url(history: true)]
public $fileSearch;
public $order = 'created_at_desc';
public $options = [
'FHD' => true,
'FHD 48fps' => true,
];
public $isOpen = false;
#[Url(history: true)]
public $studios = [];
public $studiosCopy = [];
// To toggle individual option selection
public function toggleOption($option)
{
$this->options[$option] = !$this->options[$option];
$this->resetPage();
}
// To toggle dropdown visibility
public function toggleDropdown()
{
$this->isOpen = !$this->isOpen;
}
protected $queryString = [
'fileSearch' => ['except' => '', 'as' => 'fS'],
'order' => ['except' => '', 'as' => 'order'],
@@ -24,6 +50,40 @@ class DownloadsSearch extends Component
$this->resetPage();
}
public function applyFilters(): void
{
$this->studiosCopy = $this->studios;
$this->resetPage();
}
public function revertFilters(): void
{
$this->studios = $this->studiosCopy;
}
// Map the selected options to database types
private function getSelectedTypes()
{
$types = [];
// Map the options to their corresponding database values
foreach ($this->options as $label => $selected) {
if ($selected) {
if ($label === 'FHD') {
$types[] = 'FHD';
} elseif ($label === 'FHD 48fps') {
$types[] = 'FHDi';
} elseif ($label === 'UHD' && auth()->user()->is_patreon) {
$types[] = 'UHD';
} elseif ($label === 'UHD 48fps' && auth()->user()->is_patreon) {
$types[] = 'UHDi';
}
}
}
return $types;
}
public function clicked($downloadId)
{
$download = Downloads::find($downloadId);
@@ -36,6 +96,17 @@ class DownloadsSearch extends Component
cache()->forget("episode_{$download->episode->id}_download_{$download->type}");
}
public function mount()
{
if (!auth()->user()->is_patreon) {
return;
}
// Add patreon options
$this->options['UHD'] = true;
$this->options['UHD 48fps'] = true;
}
public function render()
{
$orderby = 'created_at';
@@ -72,7 +143,8 @@ class DownloadsSearch extends Component
}
$downloads = Downloads::when($this->fileSearch != '', fn ($query) => $query->where('url', 'like', '%'.$this->fileSearch.'%'))
->when(!auth()->user()->is_patreon, fn ($query) => $query->whereIn('type', ['FHD', 'FHDi']))
->whereIn('type', $this->getSelectedTypes())
->when($this->studios !== [], fn ($q) => $q->whereHas('episode', fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); })))
->whereNotNull('size')
->orderBy($orderby, $orderdirection)
->paginate(20);
@@ -80,6 +152,7 @@ class DownloadsSearch extends Component
return view('livewire.downloads-search', [
'downloads' => $downloads,
'query' => $this->fileSearch,
'studiocount' => is_array($this->studios) ? count($this->studios) : 0,
]);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Livewire;
use App\Models\Episode;
use App\Models\Gallery;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
@@ -18,22 +17,15 @@ class NavLiveSearch extends Component
public function render()
{
$episodes = [];
$randomimage = null;
if ($this->navSearch != '') {
$episodes = Episode::with('gallery')->where('title', 'like', '%'.$this->navSearch.'%')
->orWhere('title_jpn', 'like', '%'.$this->navSearch.'%')
->when(Auth::guest(), fn ($query) => $query->withoutTags(['loli', 'shota']))
->take(10)
$episodes = Episode::search($this->navSearch)
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->take(7)
->get();
$randomimage = Gallery::all()
->random(1)
->first();
}
return view('livewire.nav-live-search', [
'episodes' => $episodes,
'randomimage' => $randomimage,
'query' => $this->navSearch,
'hide' => empty($this->navSearch),
]);

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Livewire;
use App\Models\Comment;
use Livewire\Component;
use Livewire\WithPagination;
class UserComments extends Component
{
use WithPagination;
public $model;
public $commentSearch;
public $order = 'created_at_desc';
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'created_at_desc':
$orderby = 'created_at';
$orderdirection = 'desc';
break;
case 'created_at_asc':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$comments = Comment::where('user_id', $this->model->id)
->when($this->commentSearch != '', fn ($query) => $query->where('body', 'like', '%'.$this->commentSearch.'%'))
->orderBy($orderby, $orderdirection)
->paginate(10);
return view('livewire.user-comments', [
'comments' => $comments
]);
}
}

76
app/Models/Comment.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models;
use App\Models\Presenters\CommentPresenter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
class Comment extends Model
{
use HasFactory, SoftDeletes, Markable;
protected static $marks = [
Like::class
];
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'body'
];
public function presenter()
{
return new CommentPresenter($this);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeParent(Builder $builder)
{
$builder->whereNull('parent_id');
}
public function children()
{
return $this->hasMany(Comment::class, 'parent_id')->oldest();
}
public function commentable()
{
return $this->morphTo();
}
public function parent()
{
return $this->hasOne(Comment::class, 'id', 'parent_id');
}
// Recursevly calculates how deep the nesting is
public function depth(): int
{
return $this->parent
? $this->parent->depth() + 1
: 0;
}
/**
* Get cached like count
*/
public function likeCount(): int
{
return cache()->remember('commentLikes' . $this->id, 300, fn() => $this->likes->count());
}
}

View File

@@ -8,7 +8,7 @@ use App\Models\PopularWeekly;
use App\Models\PopularDaily;
use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable;
use Laravel\Scout\Searchable;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
@@ -17,6 +17,7 @@ use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -24,13 +25,41 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Episode extends Model implements Sitemapable
{
use Commentable, Markable, Taggable;
use Markable, Taggable;
use HasFactory;
use Searchable;
protected static $marks = [
Like::class
];
/**
* Get the name of the index associated with the model.
*/
public function searchableAs(): string
{
return 'episodes_index';
}
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray()
{
return [
'title' => $this->title,
'title_search' => $this->title_search,
'title_jpn' => $this->title_jpn,
'slug' => $this->slug,
'description' => $this->description,
'tags' => $this->tagNames(),
'release_date' => $this->release_date,
'created_at' => $this->created_at,
];
}
/**
* Get the studio for the Hentai.
*/
@@ -74,10 +103,11 @@ class Episode extends Model implements Sitemapable
/**
* Increment View Count.
*/
public function incrementViewCount(): bool
public function incrementViewCount(): void
{
$this->view_count++;
return $this->save();
DB::table('episodes')
->where('id', $this->id)
->update(['view_count' => $this->view_count + 1]);
}
/**
@@ -130,6 +160,11 @@ class Episode extends Model implements Sitemapable
return cache()->remember('episodeComments' . $this->id, 300, fn() => $this->comments->count());
}
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function getProblematicTags(): string
{
$problematicTags = ['Gore', 'Scat', 'Horror'];

View File

@@ -10,11 +10,10 @@ use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable;
class Hentai extends Model implements Sitemapable
{
use Commentable, Taggable;
use Taggable;
use HasFactory;
/**
@@ -37,6 +36,11 @@ class Hentai extends Model implements Sitemapable
return $this->episodes->first()->title;
}
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
/**
* Has a Gallery.
*/

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Presenters;
use App\Models\Comment;
use Illuminate\Support\Str;
class CommentPresenter
{
public $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
public function markdownBody()
{
return Str::of($this->comment->body)->markdown([
'html_input' => 'strip',
]);
}
public function relativeCreatedAt()
{
return $this->comment->created_at->diffForHumans();
}
}

View File

@@ -2,20 +2,18 @@
namespace App\Models;
//use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Jakyeru\Larascord\Traits\InteractsWithDiscord;
use Laravelista\Comments\Commenter;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, InteractsWithDiscord, Commenter;
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@@ -23,22 +21,14 @@ class User extends Authenticatable
* @var string[]
*/
protected $fillable = [
'id',
'username',
'global_name',
'discriminator',
'name',
'email',
'avatar',
'verified',
'banner',
'banner_color',
'accent_color',
'password',
'locale',
'mfa_enabled',
'premium_type',
'public_flags',
'roles',
'is_banned',
// Discord
'discord_id',
'discord_avatar',
];
/**
@@ -47,6 +37,7 @@ class User extends Authenticatable
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
@@ -56,24 +47,21 @@ class User extends Authenticatable
* @var array
*/
protected $casts = [
'id' => 'integer',
'username' => 'string',
'global_name' => 'string',
'discriminator' => 'string',
// Laravel defaults
'email_verified_at' => 'datetime',
'password' => 'hashed',
// Other
'name' => 'string',
'email' => 'string',
'avatar' => 'string',
'verified' => 'boolean',
'banner' => 'string',
'banner_color' => 'string',
'accent_color' => 'string',
'locale' => 'string',
'mfa_enabled' => 'boolean',
'premium_type' => 'integer',
'public_flags' => 'integer',
'roles' => 'json',
'tag_blacklist' => 'array',
// Discord
'discord_id' => 'integer',
'discord_avatar' => 'string',
];
/**
* Has Many Playlists.
*/
@@ -101,8 +89,34 @@ class User extends Authenticatable
/**
* Has Many Comments.
*/
public function comments()
{
return $this->hasMany(Comment::class, 'user_id');
}
/**
* Get Comment Count.
*/
public function commentCount(): int
{
return DB::table('comments')->where('commenter_id', $this->id)->count();
return cache()->remember('userComments' . $this->id, 300, fn() => $this->comments->count());
}
/**
* Returns the user avatar image url.
*/
public function getAvatar(): string
{
if ($this->discord_id && $this->discord_avatar && !$this->avatar)
{
return "https://external-content.duckduckgo.com/iu/?u={$this->discord_avatar}";
}
if ($this->avatar)
{
return Storage::url($this->avatar);
}
return asset('images/default-avatar.webp');
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace Laravelista\Comments;
use Laravelista\Comments\Comment;
class CommentPolicy
{
/**
* Can user create the comment
*
* @param $user
* @return bool
*/
public function create($user) : bool
{
return true;
}
/**
* Can user delete the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function delete($user, Comment $comment) : bool
{
return ($user->getKey() == $comment->commenter_id) || $user->is_admin;
}
/**
* Can user update the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function update($user, Comment $comment) : bool
{
return $user->getKey() == $comment->commenter_id;
}
/**
* Can user reply to the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function reply($user, Comment $comment) : bool
{
return $user->getKey() != $comment->commenter_id;
}
}

View File

@@ -1,139 +0,0 @@
<?php
namespace Laravelista\Comments;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use App\Notifications\CommentNotification;
use App\Models\User;
use App\Models\Episode;
class CommentService
{
/**
* Handles creating a new comment for given model.
* @return mixed the configured comment-model
*/
public function store(Request $request)
{
// If guest commenting is turned off, authorize this action.
if (Config::get('comments.guest_commenting') == false) {
Gate::authorize('create-comment', Comment::class);
}
// Define guest rules if user is not logged in.
if (!Auth::check()) {
$guest_rules = [
'guest_name' => 'required|string|max:255',
'guest_email' => 'required|string|email|max:255',
];
}
// Merge guest rules, if any, with normal validation rules.
Validator::make($request->all(), array_merge($guest_rules ?? [], [
'commentable_type' => 'required|string',
'commentable_id' => 'required|string|min:1',
'message' => 'required|string'
]))->validate();
$model = $request->commentable_type::findOrFail($request->commentable_id);
$commentClass = Config::get('comments.model');
$comment = new $commentClass;
if (!Auth::check()) {
$comment->guest_name = $request->guest_name;
$comment->guest_email = $request->guest_email;
} else {
$comment->commenter()->associate(Auth::user());
}
$comment->commentable()->associate($model);
$comment->comment = $request->message;
$comment->approved = !Config::get('comments.approval_required');
$comment->save();
return $comment;
}
/**
* Handles updating the message of the comment.
* @return mixed the configured comment-model
*/
public function update(Request $request, Comment $comment)
{
Gate::authorize('edit-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$comment->update([
'comment' => $request->message
]);
return $comment;
}
/**
* Handles deleting a comment.
* @return mixed the configured comment-model
*/
public function destroy(Comment $comment): void
{
Gate::authorize('delete-comment', $comment);
if (Config::get('comments.soft_deletes') == true) {
$comment->delete();
} else {
$comment->forceDelete();
}
}
/**
* Handles creating a reply "comment" to a comment.
* @return mixed the configured comment-model
*/
public function reply(Request $request, Comment $comment)
{
Gate::authorize('reply-to-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$commentClass = Config::get('comments.model');
$reply = new $commentClass;
$reply->commenter()->associate(Auth::user());
$reply->commentable()->associate($comment->commentable);
$reply->parent()->associate($comment);
$reply->comment = $request->message;
$reply->approved = !Config::get('comments.approval_required');
$reply->save();
// Notify
if ($comment->commentable_type == 'App\Models\Episode') {
$episode = Episode::where('id', $comment->commentable_id)->firstOrFail();
$url = '/hentai/' . $episode->slug . '#comment-' . $reply->id;
$user = Auth::user();
$username = $user->global_name ?? $user->username;
$parentCommentUser = User::where('id', $comment->commenter_id)->firstOrFail();
$parentCommentUser->notify(
new CommentNotification(
"{$username} replied to your comment.",
Str::limit($reply->comment, 50),
$url
)
);
}
return $reply;
}
}

View File

@@ -1,155 +0,0 @@
<?php
namespace Jakyeru\Larascord\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Jakyeru\Larascord\Http\Requests\StoreUserRequest;
use Jakyeru\Larascord\Services\DiscordService;
use RealRashid\SweetAlert\Facades\Alert;
class DiscordController extends Controller
{
/**
* Handles the Discord OAuth2 login.
*/
public function handle(StoreUserRequest $request): RedirectResponse | JsonResponse
{
// Making sure the "guilds" scope was added to .env if there are any guilds specified in "larascord.guilds".
if (count(config('larascord.guilds'))) {
if (!in_array('guilds', explode('&', config('larascord.scopes')))) {
return $this->throwError('missing_guilds_scope');
}
}
// Getting the accessToken from the Discord API.
try {
$accessToken = (new DiscordService())->getAccessTokenFromCode($request->get('code'));
} catch (\Exception $e) {
return $this->throwError('invalid_code', $e);
}
// Get the user from the Discord API.
try {
$user = (new DiscordService())->getCurrentUser($accessToken);
$user->setAccessToken($accessToken);
} catch (\Exception $e) {
return $this->throwError('authorization_failed', $e);
}
// Making sure the user has an email if the email scope is set.
if (in_array('email', explode('&', config('larascord.scopes')))) {
if (empty($user->email)) {
return $this->throwError('missing_email');
}
}
if (auth()->check()) {
// Making sure the current logged-in user's ID is matching the ID retrieved from the Discord API.
if (auth()->id() !== (int)$user->id) {
auth()->logout();
return $this->throwError('invalid_user');
}
// Confirming the session in case the user was redirected from the password.confirm middleware.
$request->session()->put('auth.password_confirmed_at', time());
}
// Trying to create or update the user in the database.
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
try {
$user = (new DiscordService())->createOrUpdateUser($user);
$user->accessToken()->updateOrCreate([], $accessToken->toArray());
} catch (\Exception $e) {
DB::rollBack();
return $this->throwError('database_error', $e);
}
// Verifying if the user is soft-deleted.
if (Schema::hasColumn('users', 'deleted_at')) {
if ($user->trashed()) {
DB::rollBack();
return $this->throwError('user_deleted');
}
}
// Patreon check
try {
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) {
DB::rollBack();
return $this->throwError('missing_guilds_members_read_scope');
}
$guildMember = (new DiscordService())->getGuildMember($accessToken, config('discord.guild_id'));
$patreonroles = config('discord.patreon_roles');
$user->is_patreon = false;
if ((new DiscordService())->hasRoleInGuild($guildMember, $patreonroles)) {
$user->is_patreon = true;
}
$user->save();
} catch (\Exception $e) {
// Clearly not a patreon
$user->is_patreon = false;
$user->save();
}
// Committing the database transaction.
DB::commit();
// Authenticating the user if the user is not logged in.
if (!auth()->check()) {
auth()->login($user, config('larascord.remember_me', false));
}
// Redirecting the user to the intended page or to the home page.
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Handles the throwing of an error.
*/
private function throwError(string $message, \Exception $exception = NULL): RedirectResponse | JsonResponse
{
if (app()->hasDebugModeEnabled()) {
return response()->json([
'larascord_message' => config('larascord.error_messages.' . $message),
'message' => $exception?->getMessage(),
'code' => $exception?->getCode()
]);
} else {
if (config('larascord.error_messages.' . $message . '.redirect')) {
Alert::error('Error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
return redirect(config('larascord.error_messages.' . $message . '.redirect'))->with('error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
} else {
return redirect('/')->with('error', config('larascord.error_messages.' . $message, 'An error occurred while trying to log you in.'));
}
}
}
/**
* Handles the deletion of the user.
*/
public function destroy(): RedirectResponse | JsonResponse
{
// Revoking the OAuth2 access token.
try {
(new DiscordService())->revokeAccessToken(auth()->user()->accessToken()->first()->refresh_token);
} catch (\Exception $e) {
return $this->throwError('revoke_token_failed', $e);
}
// Deleting the user from the database.
auth()->user()->delete();
// Showing the success message.
if (config('larascord.success_messages.user_deleted.redirect')) {
return redirect(config('larascord.success_messages.user_deleted.redirect'))->with('success', config('larascord.success_messages.user_deleted.message', 'Your account has been deleted.'));
} else {
return redirect('/')->with('success', config('larascord.success_messages.user_deleted', 'Your account has been deleted.'));
}
}
}

View File

@@ -1,273 +0,0 @@
<?php
namespace Jakyeru\Larascord\Services;
use App\Models\User;
use App\Models\OldUser;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Jakyeru\Larascord\Types\AccessToken;
use Jakyeru\Larascord\Types\GuildMember;
use Illuminate\Support\Facades\DB;
class DiscordService
{
/**
* The Discord OAuth2 token URL.
*/
protected string $tokenURL = "https://discord.com/api/oauth2/token";
/**
* The Discord API base URL.
*/
protected string $baseApi = "https://discord.com/api";
/**
* The required data for the token request.
*/
protected array $tokenData = [
"client_id" => NULL,
"client_secret" => NULL,
"grant_type" => "authorization_code",
"code" => NULL,
"redirect_uri" => NULL,
"scope" => null
];
/**
* UserService constructor.
*/
public function __construct()
{
$this->tokenData['client_id'] = config('larascord.client_id');
$this->tokenData['client_secret'] = config('larascord.client_secret');
$this->tokenData['grant_type'] = config('larascord.grant_type');
$this->tokenData['redirect_uri'] = config('larascord.redirect_uri');
$this->tokenData['scope'] = config('larascord.scopes');
}
/**
* Handles the Discord OAuth2 callback and returns the access token.
*
* @throws RequestException
*/
public function getAccessTokenFromCode(string $code): AccessToken
{
$this->tokenData['code'] = $code;
$response = Http::asForm()->post($this->tokenURL, $this->tokenData);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Get access token from refresh token.
*
* @throws RequestException
*/
public function refreshAccessToken(string $refreshToken): AccessToken
{
$response = Http::asForm()->post($this->tokenURL, [
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Authenticates the user with the access token and returns the user data.
*
* @throws RequestException
*/
public function getCurrentUser(AccessToken $accessToken): \Jakyeru\Larascord\Types\User
{
$response = Http::withToken($accessToken->access_token)->get($this->baseApi . '/users/@me');
$response->throw();
return new \Jakyeru\Larascord\Types\User(json_decode($response->body()));
}
/**
* Get the user's guilds.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserGuilds(AccessToken $accessToken, bool $withCounts = false): array
{
if (!$accessToken->hasScope('guilds')) throw new Exception(config('larascord.error_messages.missing_guilds_scope.message'));
$endpoint = '/users/@me/guilds';
if ($withCounts) {
$endpoint .= '?with_counts=true';
}
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . $endpoint);
$response->throw();
return array_map(function ($guild) {
return new \Jakyeru\Larascord\Types\Guild($guild);
}, json_decode($response->body()));
}
/**
* Get the Guild Member object for a user.
*
* @throws RequestException
* @throws Exception
*/
public function getGuildMember(AccessToken $accessToken, string $guildId): GuildMember
{
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) throw new Exception(config('larascord.error_messages.missing_guilds_members_read_scope.message'));
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/guilds/' . $guildId . '/member');
$response->throw();
return new GuildMember(json_decode($response->body()));
}
/**
* Get the User's connections.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserConnections(AccessToken $accessToken): array
{
if (!$accessToken->hasScope('connections')) throw new Exception('The "connections" scope is required.');
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/connections');
$response->throw();
return array_map(function ($connection) {
return new \Jakyeru\Larascord\Types\Connection($connection);
}, json_decode($response->body()));
}
/**
* Join a guild.
*
* @throws RequestException
* @throws Exception
*/
public function joinGuild(AccessToken $accessToken, User $user, string $guildId, array $options = []): GuildMember
{
if (!config('larascord.access_token')) throw new Exception(config('larascord.error_messages.missing_access_token.message'));
if (!$accessToken->hasScope('guilds.join')) throw new Exception('The "guilds" and "guilds.join" scopes are required.');
$response = Http::withToken(config('larascord.access_token'), 'Bot')->put($this->baseApi . '/guilds/' . $guildId . '/members/' . $user->id, array_merge([
'access_token' => $accessToken->access_token,
], $options));
$response->throw();
if ($response->status() === 204) return throw new Exception('User is already in the guild.');
return new GuildMember(json_decode($response->body()));
}
/**
* Create or update a user in the database.
*
* @throws Exception
*/
public function createOrUpdateUser(\Jakyeru\Larascord\Types\User $user): User
{
if (!$user->getAccessToken()) {
throw new Exception('User access token is missing.');
}
$forgottenUser = User::where('email', '=', $user->email)->where('id', '!=', $user->id)->first();
if ($forgottenUser) {
// This case should never happen (TM) - The discord id changed
// The user probably re-created their discord account with the same email
// Delete Playlist
$playlists = Playlist::where('user_id', $forgottenUser->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $forgottenUser->id)->update(['commenter_id' => 1]);
$forgottenUser->forceDelete();
}
return User::updateOrCreate(
[
'id' => $user->id,
],
$user->toArray(),
);
}
/**
* Verify if the user is in the specified guild(s).
*/
public function isUserInGuilds(array $guilds): bool
{
// Verify if the user is in all the specified guilds if strict mode is enabled.
if (config('larascord.guilds_strict')) {
return empty(array_diff(config('larascord.guilds'), array_column($guilds, 'id')));
}
// Verify if the user is in any of the specified guilds if strict mode is disabled.
return !empty(array_intersect(config('larascord.guilds'), array_column($guilds, 'id')));
}
/**
* Verify if the user has the specified role(s) in the specified guild.
*/
public function hasRoleInGuild(GuildMember $guildMember, array $roles): bool
{
// Verify if the user has any of the specified roles.
return !empty(array_intersect($roles, $guildMember->roles));
}
/**
* Updates the user's roles in the database.
*/
public function updateUserRoles(User $user, GuildMember $guildMember, int $guildId): void
{
// Updating the user's roles in the database.
$updatedRoles = $user->roles;
$updatedRoles[$guildId] = $guildMember->roles;
$user->roles = $updatedRoles;
$user->save();
}
/**
* Revoke the user's access token.
*
* @throws RequestException
*/
public function revokeAccessToken(string $accessToken): object
{
$response = Http::asForm()->post($this->tokenURL . '/revoke', [
'token' => $accessToken,
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
]);
$response->throw();
return json_decode($response->body());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Comment;
use Illuminate\Auth\Access\HandlesAuthorization;
class CommentPolicy
{
use HandlesAuthorization;
public function update(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
public function destroy(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class);
});
}
}

View File

@@ -34,8 +34,8 @@ class EpisodeService
Request $request,
Hentai $hentai,
int $episodeNumber,
Studios $studio = null,
Episode $referenceEpisode = null
?Studios $studio = null,
?Episode $referenceEpisode = null
): Episode
{
$episode = new Episode();
@@ -74,6 +74,7 @@ class EpisodeService
$episode->interpolated = $request->input('interpolated') == 'yes';
$episode->interpolated_uhd = $request->input('downloadUHDi1') ? true : false;
$episode->is_dvd_aspect = $request->input('dvd') == 'yes';
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
$episode->save();
// Tagging

View File

@@ -74,7 +74,7 @@ class GalleryService
foreach ($oldGallery as $oldImage) {
Storage::disk('public')->delete($oldImage->image_url);
Storage::disk('public')->delete($oldImage->thumbnail_url);
$oldImage->forceDelete();
$oldImage->delete();
}
}
}

View File

@@ -11,26 +11,29 @@
"php": "^8.2",
"guzzlehttp/guzzle": "^7.8.1",
"hisorange/browser-detect": "^5.0",
"intervention/image": "^3.9",
"intervention/image-laravel": "^1.3",
"jakyeru/larascord": "^6.0",
"laravel/framework": "^11.0",
"laravel/sanctum": "^4.0",
"http-interop/http-factory-guzzle": "^1.2",
"intervention/image": "^3.11",
"intervention/image-laravel": "^1.5",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/scout": "^10.20",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10",
"laravelista/comments": "dev-l11-compatibility",
"livewire/livewire": "^3.6.4",
"livewire/livewire": "^3.7.0",
"maize-tech/laravel-markable": "^2.3.0",
"mews/captcha": "3.4.4",
"meilisearch/meilisearch-php": "^1.16",
"mews/captcha": "^3.4.4",
"predis/predis": "^2.2",
"realrashid/sweet-alert": "^7.2",
"rtconner/laravel-tagging": "^4.1",
"spatie/laravel-discord-alerts": "^1.5",
"spatie/laravel-sitemap": "^7.3",
"vluzrmos/language-detector": "^2.3"
"rtconner/laravel-tagging": "^5.0",
"socialiteproviders/discord": "^4.2",
"spatie/laravel-discord-alerts": "^1.8",
"spatie/laravel-sitemap": "^7.3"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.14.7",
"barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.24.0",
"laravel/breeze": "^2.3",
"laravel/pint": "^1.18",
"laravel/sail": "^1.38",
"mockery/mockery": "^1.4.4",
@@ -45,23 +48,13 @@
}
],
"autoload": {
"exclude-from-classmap": [
"vendor/jakyeru/larascord/src/Http/Services/DiscordService.php",
"vendor/jakyeru/larascord/src/Http/Controllers/DiscordController.php",
"vendor/laravelista/comments/src/CommentPolicy.php",
"vendor/laravelista/comments/src/CommentService.php"
],
"exclude-from-classmap": [],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"app/Override/Discord/Services/DiscordService.php",
"app/Override/Discord/DiscordController.php",
"app/Override/Comments/CommentPolicy.php",
"app/Override/Comments/CommentService.php"
]
"files": []
},
"autoload-dev": {
"psr-4": {

3013
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,18 @@ return [
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Supported Locales
|--------------------------------------------------------------------------
|
| This is used to display the supported locales by this app, it also is
| used to verify session data and requests in the SetLocale Middleware
|
*/
'supported_locales' => ['en', 'de', 'fr'],
/*
|--------------------------------------------------------------------------
| Encryption Key

View File

@@ -7,6 +7,7 @@ return [
'webhook_urls' => [
'default' => env('DISCORD_ALERT_WEBHOOK'),
'update' => env('DISCORD_ALERT_UPDATE_WEBHOOK'),
'rerelease' => env('DISCORD_ALERT_RERELEASE_WEBHOOK'),
],
/*

View File

@@ -1,10 +1,19 @@
<?php
return [
'invite_link' => 'https://discord.gg/yAqgVKNgG5',
'guild_id' => 802233383710228550,
'patreon_roles' => [841798154999169054, 803329707650187364, 803327903659196416, 803325441942356059, 803322725576736858, 802270568912519198, 802234830384267315],
'patreon_roles' => [
841798154999169054, // ????
803329707650187364, // Tier-5
803327903659196416, // ????
803325441942356059, // Tier-3
803322725576736858, // Tier-2
802270568912519198, // Tier-1
802234830384267315 // admin
],
'discord_bot_token' => env('DISCORD_BOT_TOKEN'),
];

View File

@@ -1,46 +0,0 @@
<?php
return [
/*
* Indicates whenever should autodetect and apply the language of the request.
*/
'autodetect' => env('LANG_DETECTOR_AUTODETECT', true),
/*
* Default driver to use to detect the request language.
*
* Available: browser, subdomain, uri.
*/
'driver' => env('LANG_DETECTOR_DRIVER', 'browser'),
/*
* Used on subdomain and uri drivers. That indicates which segment should be used
* to verify the language.
*/
'segment' => env('LANG_DETECTOR_SEGMENT', 0),
/*
* Languages available on the application.
*
* You could use parse_langs_to_array to use the string syntax
* or just use the array of languages with its aliases.
*/
'languages' => parse_langs_to_array(
env('LANG_DETECTOR_LANGUAGES', ['en', 'de', 'fr'])
),
/*
* Indicates if should store detected locale on cookies
*/
'cookie' => (bool) env('LANG_DETECTOR_COOKIE', true),
/*
* Indicates if should encrypt cookie
*/
'cookie_encrypt' => (bool) env('LANG_DETECTOR_COOKIE_ENCRYPT', false),
/*
* Cookie name
*/
'cookie_name' => env('LANG_DETECTOR_COOKIE', 'locale'),
];

View File

@@ -1,247 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application ID
|--------------------------------------------------------------------------
|
| This is the ID of your Discord application.
|
*/
'client_id' => env('LARASCORD_CLIENT_ID', null),
/*
|--------------------------------------------------------------------------
| Application Secret
|--------------------------------------------------------------------------
|
| This is the secret of your Discord application.
|
*/
'client_secret' => env('LARASCORD_CLIENT_SECRET', null),
/*
|--------------------------------------------------------------------------
| Application Access Token
|--------------------------------------------------------------------------
|
| This is the access token of your Discord application.
|
*/
'access_token' => env('LARASCORD_ACCESS_TOKEN', null),
/*
|--------------------------------------------------------------------------
| Grant Type
|--------------------------------------------------------------------------
|
| This is the grant type of your Discord application. It must be set to
| "authorization_code".
|
*/
'grant_type' => env('LARASCORD_GRANT_TYPE', 'authorization_code'),
/*
|--------------------------------------------------------------------------
| Redirect URI
|--------------------------------------------------------------------------
|
| This is the URI that Discord will redirect to after the user authorizes
| your application.
|
*/
'redirect_uri' => env('APP_URL', 'http://localhost:8000') . '/' . env('LARASCORD_PREFIX', 'larascord') . '/callback',
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
|
| These are the OAuth2 scopes of your Discord application.
|
*/
'scopes' => env('LARASCORD_SCOPE', 'identify&email&guilds&guilds.members.read'),
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
|
| This is the prefix that Larascord will use for its routes. For example,
| the prefix "larascord" will result in the route
| "https://domain.com/larascord/login".
|
*/
'route_prefix' => env('LARASCORD_PREFIX', 'larascord'),
/*
|--------------------------------------------------------------------------
| OAuth2 Prompt - "none" or "consent"
|--------------------------------------------------------------------------
|
| The prompt controls how the authorization flow handles existing authorizations.
| If a user has previously authorized your application with the requested scopes
| and prompt is set to consent,it will request them to re-approve their
| authorization. If set to none, it will skip the authorization screen
| and redirect them back to your redirect URI without requesting
| their authorization.
|
*/
'prompt' => 'none',
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds
|--------------------------------------------------------------------------
|
| This option restricts access to the application to users who are members
| of specific Discord guilds. Users who are not members of the specified
| guilds will not be able to use the application.
|
*/
'guilds' => [],
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds - Strict Mode
|--------------------------------------------------------------------------
|
| Enabling this option will require the user to be a member of ALL the
| aforementioned guilds. If this option is disabled, the user will
| only need to be a member of at least ONE of the guilds.
|
*/
'guilds_strict' => false,
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Roles
|--------------------------------------------------------------------------
|
| When this option is enabled, the user will only be able to use the
| application if they have at least one of the specified roles.
|
*/
// WARNING: This feature makes one request to the Discord API for each guild you specify. (Because you need to fetch the roles for each guild)
// At the moment the database is not checked for roles when the user logs in. It will always fetch the roles from the Discord API.
// Currently, the roles are only updated in the database when the user logs in. The roles from the database can be used in a middleware.
// I'm working on a better way to do this, but for now, this will work.
'guild_roles' => [
// 'guild_id' => [
// 'role_id',
// 'role_id',
// ],
],
/*
|--------------------------------------------------------------------------
| Remember Me
|--------------------------------------------------------------------------
|
| Whether or not to remember the user after they log in.
|
*/
'remember_me' => true,
/*
|--------------------------------------------------------------------------
| Error Messages
|--------------------------------------------------------------------------
|
| These are the error messages that will be displayed to the user if there
| is an error.
|
*/
'error_messages' => [
'missing_code' => [
'message' => 'The authorization code is missing.',
'redirect' => '/'
],
'invalid_code' => [
'message' => 'The authorization code is invalid.',
'redirect' => '/'
],
'authorization_failed' => [
'message' => 'The authorization failed.',
'redirect' => '/'
],
'missing_email' => [
'message' => 'Couldn\'t get your e-mail address. Please add an e-mail address to your Discord account!',
'redirect' => '/'
],
'invalid_user' => [
'message' => 'The user ID doesn\'t match the logged-in user.',
'redirect' => '/'
],
'database_error' => [
'message' => 'There was an error with the database. Please try again later.',
'redirect' => '/'
],
'missing_guilds_scope' => [
'message' => 'The "guilds" scope is required.',
'redirect' => '/'
],
'missing_guilds_members_read_scope' => [
'message' => 'The "guilds" and "guilds.members.read" scopes are required.',
'redirect' => '/'
],
'authorization_failed_guilds' => [
'message' => 'Couldn\'t get the servers you\'re in.',
'redirect' => '/'
],
'not_member_guild_only' => [
'message' => 'You are not a member of the required guilds.',
'redirect' => '/'
],
'missing_access_token' => [
'message' => 'The access token is missing.',
'redirect' => '/'
],
'authorization_failed_roles' => [
'message' => 'Couldn\'t get the roles you have.',
'redirect' => '/'
],
'missing_role' => [
'message' => 'You don\'t have the required roles.',
'redirect' => '/'
],
'revoke_token_failed' => [
'message' => 'An error occurred while trying to revoke your access token.',
'redirect' => '/'
],
],
/*
|--------------------------------------------------------------------------
| Success Messages
|--------------------------------------------------------------------------
|
| These are the success messages that will be displayed to the user if there
| is no error.
|
*/
'success_messages' => [
'user_deleted' => [
'message' => 'Your account has been deleted.',
'redirect' => '/'
],
],
];

223
config/scout.php Normal file
View File

@@ -0,0 +1,223 @@
<?php
use App\Models\Episode;
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Episode::class => [
'filterableAttributes' => [
'title',
'title_search',
'title_jpn',
'slug',
'description',
'tags'
],
'sortableAttributes' => [
'created_at',
'release_date',
'title'
],
],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
],
];

View File

@@ -31,4 +31,21 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
/*
|--------------------------------------------------------------------------
| Socialite Providers
|--------------------------------------------------------------------------
*/
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => '/auth/discord/callback',
// optional
'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true),
'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'webp'), // only pick from jpg, png, webp
],
];

View File

@@ -5,6 +5,7 @@ use App\Models\Downloads;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
@@ -14,7 +15,7 @@ return new class extends Migration
public function up(): void
{
# Delete entries with "#" as URL
Downloads::where('url', '#')->forceDelete();
Downloads::where('url', '#')->delete();
# Remove duplicate entries
$duplicates = DB::table('downloads')

View File

@@ -0,0 +1,28 @@
<?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('episodes', function (Blueprint $table) {
$table->boolean('dmca_takedown')->default(0)->after('interpolated_uhd');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('episodes', function (Blueprint $table) {
$table->dropColumn('dmca_takedown');
});
}
};

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\User;
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
{
// Remove tables from larascord
Schema::dropIfExists('discord_access_tokens');
Schema::dropIfExists('personal_access_tokens');
// Drop columns from larascord
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('discriminator');
$table->dropColumn('remember_token');
$table->dropColumn('banner');
$table->dropColumn('banner_color');
$table->dropColumn('accent_color');
$table->dropColumn('premium_type');
$table->dropColumn('public_flags');
$table->dropColumn('verified');
$table->dropColumn('mfa_enabled');
$table->dropColumn('global_name');
$table->dropColumn('locale');
});
// Change & Add Columns
Schema::table('users', function (Blueprint $table) {
// Rename
$table->renameColumn('username', 'name');
$table->renameColumn('avatar', 'discord_avatar');
$table->string('avatar')->nullable()->after('email');
// Re-Add Email verification
$table->timestamp('email_verified_at')->nullable()->after('email');
// Re-Add Password Auth
$table->string('password')->nullable()->after('email_verified_at');
$table->rememberToken()->after('password');
});
/**
* --------------------------------------------------------------------
* Fix Discord Profile Pictures
* --------------------------------------------------------------------
* The oauth package by socialite now returns a full url of the avatar.
* Meaning all the old entries have to be fixed.
*/
foreach (User::whereNotNull('discord_avatar')->get() as $user)
{
$isGif = preg_match('/a_.+/m', $user->discord_avatar) === 1;
$extension = $isGif ? 'gif' : 'webp';
$user->discord_avatar = sprintf('https://cdn.discordapp.com/avatars/%s/%s.%s', $user->id, $user->discord_avatar, $extension);
$user->save();
}
}
};

View File

@@ -0,0 +1,207 @@
<?php
use App\Models\Playlist;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create new column discord_id
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('discord_id')->nullable()->after('id');
});
// 2. Migrate Discord Users IDs
DB::table('users')
->where('id', '>', 10000)
->update(['discord_id' => DB::raw('id')]);
// 3. Temporary new auto increment column
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('new_id')->first();
});
// 3.5 Count
DB::statement('
UPDATE users u
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM users
) t ON u.id = t.id
SET u.new_id = t.rn
');
// 4. Drop foreign keys
$this->dropForeignKeys();
// 5. Fix ID's in other tables
$this->updateUserIDsInOtherTables();
// 6. Remove old ID
Schema::table('users', function (Blueprint $table) {
$table->bigInteger('id')->unsigned()->change();
$table->dropPrimary('id');
$table->dropColumn('id');
});
// 7. Rename new_id to id
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('new_id', 'id');
});
// 8. Change new ID to auto increment and set as primary key
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('id')->autoIncrement()->primary()->change();
});
// 9. Remove data that would conflict with constraints
$this->deleteUnreferencedData();
// 9. Recreate foreign key constraints
$this->addForeignKeys();
}
/**
* Drop Foreign Keys referencing the user id
*/
private function dropForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
Schema::table('watched', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
// Our Schema does include a foreign key, for whatever reason it doesn't exist in the first palce
// Schema::table('user_downloads', function (Blueprint $table) {
// $table->dropForeign(['user_id']);
// });
}
/**
* Tables to fix the IDs:
* - comments ['commenter_id']
* - markable_likes ['user_id']
* - notifications ['notifiable_id']
* - playlists ['user_id']
* - user_downloads ['user_id']
* - watched ['user_id']
*/
private function updateUserIDsInOtherTables(): void
{
DB::statement('
UPDATE comments c
JOIN users u ON c.commenter_id = u.id
SET c.commenter_id = u.new_id
');
DB::statement('
UPDATE watched w
JOIN users u ON w.user_id = u.id
SET w.user_id = u.new_id
');
DB::statement('
UPDATE markable_likes ml
JOIN users u ON ml.user_id = u.id
SET ml.user_id = u.new_id
');
DB::statement('
UPDATE notifications n
JOIN users u ON n.notifiable_id = u.id
SET n.notifiable_id = u.new_id
');
DB::statement('
UPDATE playlists p
JOIN users u ON p.user_id = u.id
SET p.user_id = u.new_id
');
DB::statement('
UPDATE user_downloads ud
JOIN users u ON ud.user_id = u.id
SET ud.user_id = u.new_id
');
}
/**
* Due to incorrect handling of user deletes,
* we have unreferenced data
*/
private function deleteUnreferencedData(): void
{
// User Downloads Table
DB::table('user_downloads')
->where('user_id', '>', 1_000_000)
->delete();
// User Playlists Table
$playlists = Playlist::where('user_id', '>', 1_000_000)
->get();
foreach($playlists as $playlist) {
DB::table('playlist_episodes')
->where('playlist_id', '=', $playlist->id)
->delete();
$playlist->delete();
}
}
/**
* Re-Add Foreign Keys to tables which we dropped previously
*/
private function addForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('watched', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('user_downloads', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('playlist_episodes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('playlist_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
});
Schema::table('playlists', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Drop Foreign Keys and Index
Schema::table('comments', function (Blueprint $table) {
$table->dropForeign(['child_id']);
$table->dropIndex(['commenter_id', 'commenter_type']);
});
// Rename and Drop columns
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('commenter_id', 'user_id');
$table->dropColumn('commenter_type');
$table->dropColumn('guest_name');
$table->dropColumn('guest_email');
$table->renameColumn('child_id', 'parent_id');
$table->renameColumn('comment', 'body');
$table->dropColumn('approved');
});
// Add Foreign Keys
Schema::table('comments', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('parent_id')->references('id')->on('comments')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Drop Foreign Keys
Schema::table('comments', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropForeign(['user_id']);
});
// Rename and Re-Add Columns
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('user_id', 'commenter_id');
$table->string('commenter_type')->nullable()->after('commenter_id');
$table->string('guest_name')->nullable()->after('commenter_type');
$table->string('guest_email')->nullable()->after('guest_name');
$table->renameColumn('parent_id', 'child_id');
$table->renameColumn('body', 'comment');
$table->boolean('approved')->default(true)->after('comment');
});
DB::table('comments')->update([
'commenter_type' => 'App\Models\User',
]);
// Re-Add foreign key constraint and index
Schema::table('comments', function (Blueprint $table) {
$table->foreign('child_id')->references('id')->on('comments')->onDelete('cascade');
$table->index(["commenter_id", "commenter_type"]);
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 10)
->nullable()
->after('discord_avatar');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

1331
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,13 @@
"build": "vite build"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.18",
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.6.8",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.1.6",
"vite-plugin-static-copy": "^3.0.1"
},

View File

@@ -1,8 +1,5 @@
import './bootstrap';
// import { Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm';
// Alpine.start();
import 'hammerjs';
import {
Collapse,
Carousel,
@@ -15,6 +12,10 @@ import {
initTE,
} from "tw-elements";
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });
// import Alpine from 'alpinejs';
import 'hammerjs';
// window.Alpine = Alpine;
// Alpine.start();
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });

View File

@@ -28,6 +28,7 @@ var av1Supported = (!!document.createElement('video').canPlayType('video/webm; c
var dashSupported = dashjs.supportsMediaSource();
var apiResponse = {};
var volume = 0.5;
var muted = false;
var captions = true;
var lastTime = 0.0;
var streamServer = '';
@@ -65,10 +66,16 @@ if (localStorage.hstreamCaptions) {
console.log('Loaded Captions Status from Local Storage: ' + captions);
}
// Load Muted from LocalStorage
if (localStorage.hstreamCaptions) {
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 Captions Status from Local Storage: ' + captions);
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
}
// Alert User when AV1 is not supported
@@ -224,6 +231,7 @@ function initPlayer() {
};
player.volume = volume;
player.muted = muted;
//player.captions.languages = ['en'];
player.captions.language = 'en';
player.captions.active = captions;
@@ -306,6 +314,8 @@ function initPlayer() {
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', () => {

View File

@@ -34,7 +34,7 @@ window.axios.get('/v1/monthly-views').then(function (response) {
plugins: {
title: {
display: true,
text: 'Views the last 30 days',
text: 'Views the last 28 days',
font: {
size: 18
}

View File

@@ -20,3 +20,45 @@ if(localStorage.theme) {
// Default Dark Theme
localStorage.theme = 'dark';
}
// Ability to disable blur effects for slower devices
const LOCAL_STORAGE_KEY = 'blur';
const blurCheckbox = document.querySelector("input[type='checkbox']#toggleBlur");
function setCSSFilter(selector, value) {
document.querySelectorAll(selector).forEach(el => {
el.style.backdropFilter = value
});
}
function applyBlur(enabled) {
if (!enabled) {
setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg', 'none');
return;
}
setCSSFilter('.backdrop-blur-lg', 'blur(16px)');
setCSSFilter('.backdrop-blur', 'blur(8px)');
setCSSFilter('.backdrop-blur-sm', 'blur(4px)');
}
function initBlurToggle() {
const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY);
const enabled = storedValue === null ? true : storedValue === 'true';
// initialize UI and DOM
applyBlur(enabled);
if (blurCheckbox) blurCheckbox.checked = enabled;
// add event listener
if (blurCheckbox) {
blurCheckbox.addEventListener('click', (e) => {
console.log("Received Event");
const isEnabled = e.target.checked;
applyBlur(isEnabled);
localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false');
});
}
}
initBlurToggle();

View File

@@ -0,0 +1,5 @@
@extends('admin.layout')
@section('content')
@livewire('admin-comment-search')
@endsection

View File

@@ -97,6 +97,20 @@
</div>
<div class="flex flex-wrap flex-shrink-0 justify-end items-center p-4 rounded-b-md">
<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" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="v2">
v2 Re-Release Notification
</label>
</div>
<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="dmca_takedown" name="dmca_takedown" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="dmca_takedown">
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">
Cancel
</button>

View File

@@ -56,6 +56,13 @@
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel
</button>

View File

@@ -19,6 +19,12 @@
<span class="ms-3">Users</span>
</a>
</li>
<li>
<a href="{{ route('admin.comments.index') }}" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-pink-700/40 group @if(Route::is('admin.comments.index')) bg-pink-700/40 @endif">
<i class="fa-solid fa-comment"></i>
<span class="ms-3">Comments</span>
</a>
</li>
<li>
<a href="{{ route('admin.contact.index') }}" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-pink-700/40 group @if(Route::is('admin.contact.index')) bg-pink-700/40 @endif">
<i class="fa-solid fa-message"></i>

View File

@@ -62,13 +62,12 @@
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description1">Description 1:</label>
<textarea rows="4" cols="50" id="description1" name="description1" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required>
</textarea>
<textarea rows="4" cols="50" id="description1" name="description1" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required></textarea>
</div>
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p 1:</label>
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" required />
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" />
</div>
<div class="p-4 pt-0">
@@ -78,7 +77,7 @@
<div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k 1:</label>
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" required />
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
</div>
<div class="p-4 pt-0">
@@ -90,6 +89,13 @@
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel
</button>

View File

@@ -1,20 +1,29 @@
<x-guest-layout>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('This is a secure area of the application. Please confirm your session before continuing.') }}
</div>
<form method="POST" action="{{ route('larascord.refresh_token') }}">
@csrf
<div class="flex justify-end mt-4">
<x-primary-button>
<svg style="margin-right: 10px;" width="30px" height="30px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#ffff" fill-rule="nonzero"></path>
</g>
</svg>
{{ __('Confirm') }}
</x-primary-button>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
</div>
</form>
<form method="POST" action="{{ route('password.confirm') }}">
@csrf
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,27 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Send Password Reset Link') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,138 @@
<x-guest-layout>
<!-- Tabs -->
<div class="w-full sm:max-w-md mt-6">
<ul class="flex list-none flex-row flex-wrap border-b-0 pl-0 relative " role="tablist" data-te-nav-ref>
<li role="presentation" class="flex-auto text-center">
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
data-te-toggle="pill" data-te-target="#tabs-login" data-te-nav-active role="tab" aria-controls="tabs-login" aria-selected="true">
{{ __('Login') }}
</a>
</li>
<li role="presentation" class="flex-auto text-center">
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
data-te-toggle="pill" data-te-target="#tabs-register" role="tab" aria-controls="tabs-register" aria-selected="false">
{{ __('Register') }}
</a>
</li>
</ul>
</div>
<!-- Login -->
<div class="w-full sm:max-w-md hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-login" role="tabpanel" aria-labelledby="tabs-login" data-te-tab-active>
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="w-full text-center text-white mb-3">
<a href="{{ route('discord.login') }}">
<div
class="relative bg-blue-700 hover:bg-blue-600 text-white font-bold px-4 h-10 rounded text-center p-[10px] mb-4">
<i class="fa-brands fa-discord"></i> {{ __('Use Discord Account') }}
</div>
</a>
<!-- Or -->
<div class="grid grid-cols-3">
<hr class="self-center border-neutral-600">
<p>OR</p>
<hr class="self-center border-neutral-600">
</div>
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('login') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember_me" class="inline-flex items-center">
<input id="remember_me" type="checkbox" class="rounded dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 text-rose-600 shadow-sm focus:ring-rose-500 dark:focus:ring-rose-600 dark:focus:ring-offset-neutral-800" name="remember">
<span class="ms-2 text-sm text-neutral-600 dark:text-neutral-400">{{ __('Remember me') }}</span>
</label>
</div>
<div class="flex items-center justify-end mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 dark:focus:ring-offset-neutral-800" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</a>
@endif
<x-primary-button class="ms-3">
{{ __('Log in') }}
</x-primary-button>
</div>
</form>
</div>
</div>
<!-- Register -->
<div class="w-full sm:max-w-md hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-register" role="tabpanel" aria-labelledby="tabs-register">
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<form method="POST" action="{{ route('register') }}">
@csrf
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,41 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<form method="POST" action="{{ route('password.store') }}">
@csrf
<!-- Password Reset Token -->
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</div>
</x-guest-layout>

View File

@@ -0,0 +1,33 @@
<x-guest-layout>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="mt-4 flex items-center justify-between">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<div>
<x-primary-button>
{{ __('Resend Verification Email') }}
</x-primary-button>
</div>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
{{ __('Log Out') }}
</button>
</form>
</div>
</div>
</x-guest-layout>

View File

@@ -1 +1 @@
<img class="h-10" src="/images/hs_banner.png">
<img class="h-16" src="/images/hs_banner.png">

View File

@@ -1 +1 @@
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100 dark:hover:bg-neutral-900 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>

View File

@@ -1,24 +1,16 @@
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-neutral-800'])
@php
switch ($align) {
case 'left':
$alignmentClasses = 'origin-top-left left-0';
break;
case 'top':
$alignmentClasses = 'origin-top';
break;
case 'right':
default:
$alignmentClasses = 'origin-top-right right-0';
break;
}
$alignmentClasses = match ($align) {
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
'top' => 'origin-top',
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
};
switch ($width) {
case '48':
$width = 'w-48';
break;
}
$width = match ($width) {
'48' => 'w-48',
default => $width,
};
@endphp
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
@@ -28,11 +20,11 @@ switch ($width) {
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
style="display: none;"
@click="open = false">

View File

@@ -40,12 +40,13 @@ $maxWidth = [
}
})"
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
class="fixed inset-0 overflow-y-auto px-4 py-12 sm:px-0 z-50"
style="display: {{ $show ? 'block' : 'none' }};"
>
<div
@@ -59,12 +60,12 @@ $maxWidth = [
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
<div class="absolute inset-0 bg-neutral-500 dark:bg-neutral-900 opacity-75"></div>
</div>
<div
x-show="show"
class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
class="mb-6 bg-white dark:bg-neutral-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"

View File

@@ -1,10 +1,9 @@
@props(['active'])
@php
$classes =
$active ?? false
? 'block w-full pl-3 pr-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-left text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-left text-base font-medium text-neutral-600 dark:text-neutral-200 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 focus:outline-none focus:text-neutral-800 dark:focus:text-neutral-200 focus:bg-neutral-50 dark:focus:bg-neutral-700 focus:border-neutral-300 dark:focus:border-neutral-600 transition duration-150 ease-in-out';
$classes = ($active ?? false)
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 focus:outline-none focus:text-neutral-800 dark:focus:text-neutral-200 focus:bg-neutral-50 dark:focus:bg-neutral-700 focus:border-neutral-300 dark:focus:border-neutral-600 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>

View File

@@ -1,3 +1,3 @@
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-500 rounded-md font-semibold text-xs text-neutral-700 dark:text-neutral-300 uppercase tracking-widest shadow-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>

View File

@@ -1,3 +1,3 @@
@props(['disabled' => false])
<input {{ $disabled ? 'disabled' : '' }} {!! $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm']) !!}>
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm']) }}>

View File

@@ -24,9 +24,8 @@
@include('home.partials.random')
</div>
<!-- Comments -->
<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%]">
<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>
</x-app-layout>

View File

@@ -2,14 +2,14 @@
{{ __('home.latest-comments') }}
</p>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<div class="grid gap-2 grid-cols-1 xl:grid-cols-2">
@foreach ($latestComments as $comment)
@if ($comment->commentable_type == 'App\Models\Episode')
@if ($comment->commentable_type == \App\Models\Episode::class)
@php $episode = cache()->rememberForever('commentEpisode'.$comment->commentable_id, fn () => App\Models\Episode::with('gallery')->where('id', $comment->commentable_id)->first()); @endphp
<div id="comments" class="flex p-4 bg-white rounded-lg dark:bg-neutral-950">
<div
class="w-[20vw] mr-5 p-1 md:p-2 mb-8 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
<a class="hidden hover:text-blue-600 xl:block"
class="w-[15vw] mr-5 p-1 md:p-2 mb-4 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
<a class="hidden 2xl:block"
href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
@@ -18,28 +18,28 @@
class="absolute right-2 top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
{{ $episode->getResolution() }}</p>
<div class="absolute w-[95%] grid grid-cols-1 text-center">
<p class="text-sm text-center text-black dark:text-white">{{ $episode->title }} -
<p class="text-sm text-center text-black dark:text-white truncate">{{ $episode->title }} -
{{ $episode->episode }}</p>
</div>
</a>
<a class="block hover:text-blue-600 xl:hidden"
<a class="block 2xl:hidden"
href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg"
src="{{ $episode->cover_url }}"></img>
</a>
</div>
<div class="w-[60vw]">
<div class="w-[60vw] pt-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg pl-4">
@include('partials.comment', ['comment' => $comment])
</div>
</div>
@elseif($comment->commentable_type == 'App\Models\Hentai')
@elseif($comment->commentable_type == \App\Models\Hentai::class)
@php $hentai = cache()->rememberForever('commentHentai'.$comment->commentable_id, fn () => App\Models\Hentai::with('gallery', 'episodes')->where('id', $comment->commentable_id)->first()); @endphp
<div id="comments" class="flex p-4 bg-white rounded-lg dark:bg-neutral-950">
<div
class="w-[20vw] mr-5 p-1 md:p-2 mb-8 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
<a class="hover:text-blue-600" href="{{ route('hentai.index', ['title' => $hentai->slug]) }}">
class="w-[15vw] mr-5 p-1 md:p-2 mb-8 relative transition ease-in-out hover:-translate-y-1 hover:scale-110 duration-300">
<a class="hidden 2xl:block" href="{{ route('hentai.index', ['title' => $hentai->slug]) }}">
<img alt="{{ $hentai->episodes->first()->title }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
src="{{ $hentai->gallery->first()->thumbnail_url }}"></img>
@@ -47,12 +47,19 @@
class="absolute right-2 top-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
{{ $hentai->episodes->first()->getResolution() }}</p>
<div class="absolute w-[95%] grid grid-cols-1 text-center">
<p class="text-sm text-center text-black dark:text-white">
<p class="text-sm text-center text-black dark:text-white truncate">
{{ $hentai->episodes->first()->title }}</p>
</div>
</a>
<a class="block 2xl:hidden"
href="{{ route('hentai.index', ['title' => $hentai->slug]) }}">
<img alt="{{ $hentai->episodes->first()->title }}" loading="lazy" width="1000"
class="block object-cover object-center relative z-20 rounded-lg"
src="{{ $hentai->episodes->first()->cover_url }}"></img>
</a>
</div>
<div class="w-[60vw]">
<div class="w-[60vw] pt-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg pl-4">
@include('partials.comment', ['comment' => $comment])
</div>
</div>

View File

@@ -1,8 +1,12 @@
<p class="leading-normal font-bold text-lg text-neutral-800 dark:text-white">
Random
Random <span class="font-light text-xs text-neutral-800/60 dark:text-white/40">(Cached for 5 minutes)</span>
</p>
@php $random = \App\Models\Episode::inRandomOrder()->limit(8)->get(); @endphp
@php
$random = \cache()->remember('random_home', 300, function () {
return \App\Models\Episode::inRandomOrder()->limit(8)->get(); ;
});
@endphp
<div class="mb-6">
@include('home.partials.tab.template', ['episodes' => $random, 'showThumbnails' => false])

View File

@@ -45,6 +45,10 @@
<a target="_blank" href="https://hentaisites.com/"
class="hover:underline md:mr-6">hentaisites.com</a>
</li>
<li>
<a target="_blank" href="https://zhentube.com/"
class="hover:underline md:mr-6">zhentube.com</a>
</li>
</ul>
<ul class="flex flex-wrap items-center mb-6 text-sm font-medium text-gray-500 sm:mb-0 dark:text-gray-400">
<li>

View File

@@ -3,17 +3,15 @@
@include('partials.head')
<body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<body class="font-sans antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-neutral-900">
<div>
<a href="/">
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
<x-application-logo class="w-24 h-24 fill-current text-gray-500" />
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
{{ $slot }}
</div>
</body>
</html>

View File

@@ -59,16 +59,20 @@
</div>
</div>
</x-dropdown-link>
{{-- Expiremental --}}
<x-dropdown-link>
@include('partials.blurswitcher')
</x-dropdown-link>
</x-slot>
</x-dropdown>
</div>
<div class="flex items-center invisible sm:visible">
<div class="items-center hidden md:flex">
@livewire('nav-live-search')
<div class="hidden lg:block pl-4">
<div class="flex flex-col items-center bg-gray-50/20 dark:bg-neutral-900/40 rounded-md">
@php $random = App\Models\Episode::inRandomOrder()->limit(1)->pluck('slug')->first(); @endphp
<a href="{{ route('hentai.index', ['title' => $random]) }}"
<a href="{{ route('hentai.random') }}"
class="cursor-pointer px-4 py-2 text-sm font-medium dark:hover:text-white text-gray-500 dark:text-white/90 focus:outline-none flex flex-col items-center md:flex-row">
<i class="fa-solid fa-shuffle"></i>
<p class="md:pl-1 pl-0">Random</p>
@@ -90,11 +94,9 @@
<button
class="inline-flex items-center px-3 py-2 border text-sm leading-4 font-medium rounded-md text-gray-500 border-neutral-300/50 dark:text-gray-400 bg-white/20 dark:bg-neutral-950/20 dark:border-neutral-800/50 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
@auth
@if (Auth::user()->avatar)
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ Auth::user()->id }}/{{ Auth::user()->avatar }}.webp"
alt="{{ Auth::user()->getTagAttribute() }}" />
@endif
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" />
@else
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
alt="Guest" />
@@ -102,7 +104,7 @@
@auth
<div style="display: flex; flex-direction: row; align-items: flex-start;">
{{ Auth::user()->getTagAttribute() }}
{{ Auth::user()->name }}
@if ($notAvailable)
<i class="fa-solid fa-bell text-rose-600"></i>
@endif
@@ -184,8 +186,8 @@
@guest
<x-dropdown-link :href="route('login')">
<div
class="relative bg-blue-700 hover:bg-blue-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-brands fa-discord"></i> {{ __('nav.login') }}
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</div>
</x-dropdown-link>
@endguest
@@ -227,15 +229,11 @@
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600 dark:bg-neutral-900/30">
<div class="flex justify-center">
@if (Auth::user()->avatar)
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="https://external-content.duckduckgo.com/iu/?u=https://cdn.discordapp.com/avatars/{{ Auth::user()->id }}/{{ Auth::user()->avatar }}.webp"
alt="{{ Auth::user()->getTagAttribute() }}" />
@else
<img class="h-8 w-8 rounded-full object-cover mr-2" src="/images/default-avatar.webp"
alt="Guest" />
@endif
<span class="font-medium text-base text-gray-800 dark:text-neutral-200">{{ Auth::user()->username }}
<img class="h-8 w-8 rounded-full object-cover mr-2"
src="{{ Auth::user()->getAvatar() }}"
alt="{{ Auth::user()->name }}" />
<span class="font-medium text-base text-gray-800 dark:text-neutral-200">
{{ Auth::user()->name }}
</span>
</div>
@@ -298,8 +296,8 @@
<div class="pb-1 text-center w-full">
<x-responsive-nav-link :href="route('login')">
<div
class="relative bg-blue-700 hover:bg-blue-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-brands fa-discord"></i> {{ __('nav.login') }}
class="relative bg-rose-700 hover:bg-rose-600 text-white font-bold px-4 h-10 rounded text-center p-[10px]">
<i class="fa-solid fa-arrow-right-to-bracket"></i> {{ __('nav.login') }}
</div>
</x-responsive-nav-link>
</div>

View File

@@ -0,0 +1,60 @@
<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">
<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>
<th scope="col" class="px-6 py-3">
Comment
<input
wire:model.live.debounce.600ms="search"
type="search"
id="live-search"
class="ml-2 w-32 h-7 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
placeholder="Search..."
>
</th>
<th scope="col" class="px-6 py-3">
</th>
<th scope="col" class="px-6 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 }}
</td>
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
{{ $comment->body }}
</th>
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
{{ $comment->created_at }}
</th>
<td class="px-6 py-4">
<button wire:click="deleteComment({{$comment->id}})" type="button" class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150 mt-2">
Delete
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{ $comments->links('pagination::tailwind') }}
</div>
</div>

View File

@@ -60,7 +60,7 @@
{{ $user->id }}
</th>
<td class="px-6 py-4">
{{ $user->global_name ?? $user->username }}
{{ $user->name }}
</td>
<td class="px-6 py-4">
{{ $user->is_patreon ? 'Yes' : 'No' }}
@@ -74,15 +74,18 @@
<td class="px-6 py-4">
{{ $user->updated_at->format('Y-m-d') }}
</td>
<td class="px-6 py-4">
<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->is_banned ? 'unban' : 'ban' }}" name="action">
<button type="submit" class="inline-block 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">
<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->is_banned ? 'Unban' : 'Ban' }}
</button>
</form>
<button wire:click="deleteUserComments('{{ $user->id }}')" class="inline-block w-full rounded bg-red-600 pl-[4px] pr-[4px] p-[1px] text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
Delete comments
</button>
</td>
</tr>
@endforeach

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