Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e719be105 | ||
|
|
7dd92aaeb2 | ||
|
|
1b2377f20f | ||
|
|
e939dd3a53 | ||
|
|
d5d35ab85e | ||
|
|
8812aaf336 | ||
|
|
dd6091d0e4 | ||
|
|
c3955c4a7c | ||
|
|
9e613c05d2 | ||
|
|
646d3e9d2a | ||
|
|
36295fcd6a | ||
|
|
9eb9535c83 | ||
|
|
4581870517 | ||
|
|
36061d1561 | ||
|
|
d1fd76c278 | ||
|
|
a2752643e9 | ||
|
|
45b5f586ba | ||
|
|
6b2a67c4cd | ||
|
|
4b21886cb9 | ||
|
|
133ab6e623 | ||
|
|
a25af30700 | ||
|
|
d9f97a672e | ||
|
|
fa4e09ad62 | ||
|
|
8aa08e705a | ||
|
|
8fdac4b483 | ||
|
|
73620a0f24 | ||
|
|
4718888de0 | ||
|
|
c7d2f25ef6 | ||
|
|
48c947d171 | ||
|
|
c9123bc7b8 | ||
|
|
3122420a3d | ||
|
|
76584651a6 | ||
|
|
1cefc6ad2d | ||
|
|
a1493462f7 | ||
|
|
56b6a83b34 | ||
|
|
5058eaff2f | ||
|
|
468820c9fe | ||
|
|
2d33e39b9b |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
APP_ENV=testing
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_KEY=base64:Zm7kR6vDfsQQmCeWTR2Mk2WQGu6fGP1UHEcZVk/hIt0=
|
||||
|
||||
# ── Hard rule — tests must never touch the shared SQLite file. ───────────
|
||||
# phpunit.xml also sets these via <env>, but having them here means Laravel
|
||||
# picks them up during the earliest bootstrap step (before any trait or
|
||||
# setUp hook can run), closing the window that let migrate:fresh wipe the
|
||||
# dev DB on 2026-10-21. If you change these values, read tests/TestCase.php's
|
||||
# assertSafeTestDatabase() guard first.
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=:memory:
|
||||
|
||||
BCRYPT_ROUNDS=4
|
||||
CACHE_DRIVER=array
|
||||
MAIL_MAILER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=array
|
||||
|
||||
TELESCOPE_ENABLED=false
|
||||
PULSE_ENABLED=false
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
|
||||
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
|
||||
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
|
||||
* the rest. --dry-run shows what would happen without touching the DB.
|
||||
*/
|
||||
class CommentsDedupeCommand extends Command
|
||||
{
|
||||
protected $signature = 'comments:dedupe
|
||||
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
|
||||
{--dry-run : Print what would be removed; do not delete}';
|
||||
|
||||
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$window = max(1, (int) $this->option('window'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
|
||||
// We ignore the window at the SQL layer and apply it per-group in PHP:
|
||||
// that avoids a correlated subquery and is fast enough for a one-shot
|
||||
// cleanup. For very large tables, chunk by user_id.
|
||||
$groups = DB::table('comments')
|
||||
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
|
||||
->groupBy('user_id', 'video_id', 'parent_id', 'body')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No duplicate (user, video, parent, body) groups found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRemoved = 0;
|
||||
$groupsTouched = 0;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
$rows = Comment::where('user_id', $g->user_id)
|
||||
->where('video_id', $g->video_id)
|
||||
->where(function ($q) use ($g) {
|
||||
$g->parent_id === null
|
||||
? $q->whereNull('parent_id')
|
||||
: $q->where('parent_id', $g->parent_id);
|
||||
})
|
||||
->where('body', $g->body)
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keeper = $rows->first();
|
||||
$cluster = collect([$keeper]);
|
||||
$previous = $keeper;
|
||||
|
||||
foreach ($rows->slice(1) as $row) {
|
||||
// Same cluster if row is within window of the previous row —
|
||||
// which is the behavior the server guard enforces today.
|
||||
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
|
||||
$cluster->push($row);
|
||||
$previous = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cluster->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toDelete = $cluster->skip(1);
|
||||
$groupsTouched++;
|
||||
$totalRemoved += $toDelete->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
|
||||
$g->user_id,
|
||||
$g->video_id,
|
||||
$g->parent_id ?? 'null',
|
||||
$keeper->id,
|
||||
$toDelete->pluck('id')->join(','),
|
||||
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($keeper, $toDelete) {
|
||||
$ids = $toDelete->pluck('id')->all();
|
||||
|
||||
// Reparent any replies that were nested under a doomed row.
|
||||
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
|
||||
|
||||
// Move likes to the keeper, dropping dupes.
|
||||
DB::table('comment_likes')
|
||||
->whereIn('comment_id', $ids)
|
||||
->update(['comment_id' => $keeper->id]);
|
||||
|
||||
Comment::whereIn('id', $ids)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s %d duplicate rows across %d groups.',
|
||||
$dryRun ? 'Would remove' : 'Removed',
|
||||
$totalRemoved,
|
||||
$groupsTouched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Resend the email-verification mail to one user or to every unverified user.
|
||||
*
|
||||
* Useful after an SMTP outage: the account was created and the user was told
|
||||
* "we sent you a link", but the mail never left because Gmail 535'd us. Once
|
||||
* the mail config is fixed, this flushes the backlog.
|
||||
*/
|
||||
class ResendVerificationCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:resend-verification
|
||||
{--user= : A specific user id OR email to resend to}
|
||||
{--all-unverified : Resend to every user with a null email_verified_at}
|
||||
{--dry-run : Print who would be mailed; do not actually send}';
|
||||
|
||||
protected $description = 'Resend the email-verification link to one user or all unverified users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$one = (string) $this->option('user');
|
||||
$all = (bool) $this->option('all-unverified');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($one === '' && ! $all) {
|
||||
$this->error('Pass either --user=<id-or-email> or --all-unverified.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($one !== '') {
|
||||
$query = ctype_digit($one)
|
||||
? User::where('id', (int) $one)
|
||||
: User::where('email', $one);
|
||||
$users = $query->get();
|
||||
} else {
|
||||
$users = User::whereNull('email_verified_at')->get();
|
||||
}
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->warn('No matching users.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$skipped++;
|
||||
$this->line(" skip #{$user->id} <{$user->email}> — already verified");
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$this->line(" would #{$user->id} <{$user->email}>");
|
||||
continue;
|
||||
}
|
||||
// sendEmailVerificationNotification is already SafeNotifier-wrapped
|
||||
// and will log instead of throwing on SMTP errors.
|
||||
$user->sendEmailVerificationNotification();
|
||||
$sent++;
|
||||
$this->line(" sent #{$user->id} <{$user->email}>");
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "Dry run complete — {$users->count()} candidates, {$skipped} already verified."
|
||||
: "Done — {$sent} sent, {$skipped} skipped.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
private const MAP = [
|
||||
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
|
||||
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
|
||||
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
|
||||
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
|
||||
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
|
||||
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
|
||||
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
|
||||
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
|
||||
'FI' => 'fi', 'FR' => 'fr',
|
||||
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
|
||||
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
|
||||
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
|
||||
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
|
||||
'JO' => 'ar', 'JP' => 'ja',
|
||||
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
|
||||
'KZ' => 'kk',
|
||||
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
|
||||
'LV' => 'lv', 'LY' => 'ar',
|
||||
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
|
||||
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
|
||||
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
|
||||
'NZ' => 'en',
|
||||
'OM' => 'ar',
|
||||
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
|
||||
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
|
||||
'QA' => 'ar',
|
||||
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
|
||||
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
|
||||
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
|
||||
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
|
||||
'TZ' => 'sw',
|
||||
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
|
||||
'VE' => 'es', 'VN' => 'vi',
|
||||
'YE' => 'ar',
|
||||
'ZA' => 'en', 'ZW' => 'en',
|
||||
];
|
||||
|
||||
public static function primary(?string $iso2): ?string
|
||||
{
|
||||
if (! $iso2) return null;
|
||||
return self::MAP[strtoupper($iso2)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BruteForceLoginAlert;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
|
||||
'details' => ['email' => $credentials['email']],
|
||||
]);
|
||||
|
||||
$this->detectBruteForce($request, $credentials['email']);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one alert to super admins when the same email has racked up
|
||||
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
|
||||
* key with the same TTL prevents the alert from re-firing on every
|
||||
* subsequent failed attempt within the same burst.
|
||||
*/
|
||||
private const BRUTE_FORCE_THRESHOLD = 5;
|
||||
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
|
||||
|
||||
private function detectBruteForce(Request $request, string $email): void
|
||||
{
|
||||
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
|
||||
$threshold = self::BRUTE_FORCE_THRESHOLD;
|
||||
|
||||
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
|
||||
// database-portable and avoids per-driver JSON operator syntax.
|
||||
$pattern = '%"email":"' . addslashes($email) . '"%';
|
||||
|
||||
$recent = AuditLog::where('action', 'user.login.failed')
|
||||
->where('created_at', '>=', now()->subMinutes($window))
|
||||
->where('details', 'like', $pattern)
|
||||
->count();
|
||||
|
||||
if ($recent < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = 'brute_force_alert:' . sha1($email);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
Cache::put($cacheKey, 1, now()->addMinutes($window));
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Forwarded-For')
|
||||
?? $request->ip()
|
||||
?? 'unknown';
|
||||
|
||||
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
|
||||
SafeNotifier::send($admin, $notification);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewUserRegistered;
|
||||
use App\Rules\NotDisposableEmail;
|
||||
use App\Rules\ReservedIdentifier;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
||||
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'birthday' => ['required', 'date', 'before:today'],
|
||||
// Age gate: must be at least 13 on sign-up day. The front-end
|
||||
// date picker already caps the year dropdown, but the server
|
||||
// must enforce it so a client bypass doesn't let a 10-year-old
|
||||
// through.
|
||||
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
|
||||
'gender' => ['required', 'in:male,female'],
|
||||
'nationality' => ['required', 'string', 'size:2'],
|
||||
]);
|
||||
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
|
||||
|
||||
// Notify all super admins of the new registration
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
|
||||
$admin->notify(new NewUserRegistered($user));
|
||||
SafeNotifier::send($admin, new NewUserRegistered($user));
|
||||
});
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +17,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,12 +36,66 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
@@ -47,12 +103,12 @@ class CommentController extends Controller
|
||||
// Reply — notify the parent comment's author
|
||||
$parent = Comment::find($request->parent_id);
|
||||
if ($parent && $parent->user_id !== $commenter->id) {
|
||||
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
|
||||
}
|
||||
} else {
|
||||
// Top-level comment — notify the video owner
|
||||
if ($video->user_id !== $commenter->id) {
|
||||
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +145,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
@@ -107,7 +194,8 @@ class CommentController extends Controller
|
||||
if ($comment->user_id !== $userId) {
|
||||
$video = $comment->video;
|
||||
if ($video) {
|
||||
$comment->user->notify(
|
||||
SafeNotifier::send(
|
||||
$comment->user,
|
||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Site-wide search across videos, channels and playlists.
|
||||
*
|
||||
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
|
||||
* site. The old /videos/search endpoint still works but redirects here, so
|
||||
* nothing bookmarked earlier breaks.
|
||||
*
|
||||
* Match surface:
|
||||
* - Video title, description, tag list, primary language and the language
|
||||
* of any secondary audio track attached to it.
|
||||
* - User name, username (handle), and short bio.
|
||||
* - Playlist name and description.
|
||||
*
|
||||
* We render the existing `videos.index` view so every search result lands in
|
||||
* the same card component the browse feeds use — one place to style, one place
|
||||
* to fix bugs.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = trim((string) $request->get('q', ''));
|
||||
|
||||
if ($query === '') {
|
||||
// No query → send the user back to the browse feed instead of an
|
||||
// empty "no results" page.
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
$like = '%' . $query . '%';
|
||||
$qLower = mb_strtolower($query);
|
||||
|
||||
// ── Videos ────────────────────────────────────────────────
|
||||
// Match on text columns AND on any audio-track language that aligns
|
||||
// with the query (e.g. searching "arabic" surfaces videos that carry
|
||||
// an Arabic track even if the primary language is English).
|
||||
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
|
||||
->pluck('video_id');
|
||||
|
||||
$videoHits = Video::public()
|
||||
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
|
||||
$q->where('title', 'like', $like)
|
||||
->orWhere('description','like', $like)
|
||||
->orWhere('tags', 'like', $like)
|
||||
->orWhereRaw('LOWER(language) = ?', [$qLower])
|
||||
->orWhereIn('id', $trackVideoIds);
|
||||
})
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
$videos = $videoHits->where('is_shorts', false)->values();
|
||||
$shorts = $videoHits->where('is_shorts', true)->values();
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────
|
||||
$channels = User::where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('username','like', $like)
|
||||
->orWhere('bio', 'like', $like);
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
// ── Playlists ─────────────────────────────────────────────
|
||||
$playlists = Playlist::where('visibility', 'public')
|
||||
->where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('description','like', $like);
|
||||
})
|
||||
->withCount('videos')
|
||||
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||
->latest()
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'shorts' => $shorts,
|
||||
'playlists' => $playlists,
|
||||
'channels' => $channels,
|
||||
'query' => $query,
|
||||
'langChips' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,14 @@ class UserController extends Controller
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
// Views first, likes as the tiebreak, created_at as the final
|
||||
// tiebreak so two videos with the same view+like counts don't
|
||||
// flip order between page loads.
|
||||
$baseQuery
|
||||
->withCount(['viewers as viewers_count', 'likes as likes_count'])
|
||||
->orderByDesc('viewers_count')
|
||||
->orderByDesc('likes_count')
|
||||
->latest();
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
@@ -262,8 +269,16 @@ class UserController extends Controller
|
||||
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Header stats must not leak unlisted/private video view counts to
|
||||
// visitors. Owners (and ?preview=1 is handled above by setting
|
||||
// $isOwner=false) see totals across every video they uploaded;
|
||||
// visitors see totals across public videos only — matching what
|
||||
// the videos count row already shows.
|
||||
$viewVideoIds = $isOwner
|
||||
? $user->videos()->pluck('id')
|
||||
: Video::public()->where('user_id', $user->id)->pluck('id');
|
||||
$totalViews = \DB::table('video_views')
|
||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||
->whereIn('video_id', $viewVideoIds)
|
||||
->count();
|
||||
|
||||
// Filter social links by visibility for the current viewer
|
||||
@@ -345,6 +360,34 @@ class UserController extends Controller
|
||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions feed — the public videos uploaded by every channel the
|
||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||
* the sidebar's "Subscriptions" item used to point at. Thin on purpose:
|
||||
* reuses the existing video-card component via the videos/index view.
|
||||
*/
|
||||
public function subscriptionsFeed()
|
||||
{
|
||||
$me = Auth::user();
|
||||
$channelIds = $me->subscriptions()->pluck('users.id');
|
||||
|
||||
$videos = Video::public()
|
||||
->whereIn('user_id', $channelIds)
|
||||
->notShorts()
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'filter' => 'subscriptions',
|
||||
'langChips' => [],
|
||||
'shorts' => collect(),
|
||||
'matches' => collect(),
|
||||
'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Liked videos
|
||||
public function liked()
|
||||
{
|
||||
|
||||
@@ -26,7 +26,66 @@ class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'shortsFocused', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-paginated feed slice for infinite scroll.
|
||||
*
|
||||
* Called by the sentinel IntersectionObserver on /videos (and single-type
|
||||
* filters). Returns rendered <x-video-card/> HTML for the next page plus
|
||||
* the next cursor, or null when there's nothing left. The first page is
|
||||
* served by index() as regular HTML, so this endpoint is only ever used
|
||||
* once an initial grid is already on screen.
|
||||
*
|
||||
* Cursor is Laravel's native cursor-pagination token, which is stable
|
||||
* across inserts (unlike `?page=N` which can skip or duplicate items
|
||||
* when a new video is uploaded between page fetches).
|
||||
*/
|
||||
public function feedMore(Request $request)
|
||||
{
|
||||
$filter = (string) $request->query('filter', 'all');
|
||||
$pageSize = 24;
|
||||
|
||||
$query = Video::public()->notShorts();
|
||||
|
||||
if (str_starts_with($filter, 'lang:')) {
|
||||
$lang = strtolower(substr($filter, 5));
|
||||
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
|
||||
$secondaryIds = VideoAudioTrack::where('language', $lang)
|
||||
->whereIn('video_id', Video::public()->pluck('id'))
|
||||
->pluck('video_id');
|
||||
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
|
||||
$query = Video::public()->whereIn('id', $ids);
|
||||
} elseif ($filter === 'shorts') {
|
||||
$query = Video::public()->shorts();
|
||||
} elseif ($filter === 'match') {
|
||||
$query = Video::public()->where('type', 'match')->notShorts();
|
||||
} elseif ($filter === 'music') {
|
||||
$query = Video::public()->where('type', 'music')->notShorts();
|
||||
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
|
||||
// Home + Latest both show the newest-first mixed-type feed.
|
||||
// Playlists intentionally stay on their own tab — this endpoint
|
||||
// only pages the video grid.
|
||||
$query = Video::public()->notShorts();
|
||||
} else {
|
||||
// Unknown filter — behave like the empty state so a crafted
|
||||
// ?filter= value can never leak the whole table.
|
||||
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$paginator = $query->latest('id')->cursorPaginate($pageSize);
|
||||
|
||||
$html = '';
|
||||
foreach ($paginator->items() as $video) {
|
||||
$html .= view('components.video-card', ['video' => $video])->render();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'items' => $html,
|
||||
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -104,9 +163,12 @@ class VideoController extends Controller
|
||||
|
||||
// ── Filtered single-type views ────────────────────────────
|
||||
if (in_array($filter, ['music', 'latest'])) {
|
||||
$query = Video::public()->notShorts();
|
||||
// Both tabs sort newest-first. Previously the music branch skipped
|
||||
// the latest() call entirely, so Eloquent fell back to id ASC and
|
||||
// the music grid opened on the oldest uploads — never surfacing
|
||||
// anything new.
|
||||
$query = Video::public()->notShorts()->latest();
|
||||
if ($filter === 'music') $query->where('type', 'music');
|
||||
else $query->latest();
|
||||
$videos = $query->limit(50)->get();
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
@@ -114,7 +176,12 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
// ── All (home) — mixed chronological feed ─────────────────
|
||||
$videos = Video::public()->latest()->limit(60)->get();
|
||||
// Videos shown in the grid are the newest non-shorts. Shorts render
|
||||
// in their own horizontally-scrolling shelf injected after the first
|
||||
// row of cards (see videos/index.blade.php), so the home grid isn't
|
||||
// diluted by 60-second portrait clips.
|
||||
$videos = Video::public()->notShorts()->latest()->limit(60)->get();
|
||||
$homeShorts = Video::public()->where('is_shorts', true)->latest()->limit(12)->get();
|
||||
|
||||
$playlistQuery = Playlist::withCount('videos')
|
||||
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||
@@ -131,7 +198,7 @@ class VideoController extends Controller
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
return view('videos.index', compact('feedItems', 'filter', 'langChips') + [
|
||||
return view('videos.index', compact('feedItems', 'filter', 'langChips', 'homeShorts') + [
|
||||
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
@@ -691,11 +758,28 @@ class VideoController extends Controller
|
||||
$viewFp = $request->cookie('_fp');
|
||||
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
|
||||
|
||||
if (Auth::check()) {
|
||||
// Owners don't count toward their own view totals. Previews, re-watches
|
||||
// from the owner's own account, and even owner-impersonation sessions
|
||||
// would otherwise inflate the number. The share/analytics screens
|
||||
// already filter owner_id separately, so skipping the insert is safe.
|
||||
$isOwnerViewing = Auth::check() && Auth::id() === (int) $video->user_id;
|
||||
|
||||
if (Auth::check() && ! $isOwnerViewing) {
|
||||
// 30-minute user-level dedupe. Also dedupe against the strongest
|
||||
// device signal available (fingerprint > device_id cookie) so a
|
||||
// user signing out and back in during the same session doesn't
|
||||
// double-count.
|
||||
$exists = \DB::table('video_views')
|
||||
->where('user_id', Auth::id())
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->where('watched_at', '>', now()->subMinutes(30))
|
||||
->where(function ($q) use ($viewFp, $viewDid) {
|
||||
$q->where('user_id', Auth::id());
|
||||
if ($viewFp) {
|
||||
$q->orWhere('device_hash', $viewFp);
|
||||
} elseif ($viewDid) {
|
||||
$q->orWhere('device_id', $viewDid);
|
||||
}
|
||||
})
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
@@ -711,12 +795,13 @@ class VideoController extends Controller
|
||||
'watched_at' => now(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
|
||||
} elseif (! $isOwnerViewing) {
|
||||
// Guest: 30-minute dedupe keyed on the strongest device signal
|
||||
// (fingerprint hash > device_id cookie).
|
||||
$exists = \DB::table('video_views')
|
||||
->whereNull('user_id')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->where('watched_at', '>', now()->subMinutes(30))
|
||||
->where(function ($q) use ($viewFp, $viewDid) {
|
||||
if ($viewFp) $q->where('device_hash', $viewFp);
|
||||
else $q->where('device_id', $viewDid);
|
||||
@@ -778,12 +863,60 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$recommendedVideos = Video::public()
|
||||
// Up Next recommendations: a weighted blend instead of the plain
|
||||
// chronological "newest 20" the sidebar used to show.
|
||||
//
|
||||
// +4 same type (music / match / generic)
|
||||
// +3 same primary language
|
||||
// +2 overlapping tag (any match in the comma-separated tags list)
|
||||
// +1 same channel (keeps a creator's recent uploads surfaced, but
|
||||
// does not dominate — cross-channel results compete on type/
|
||||
// language/tags scoring)
|
||||
//
|
||||
// Candidates: the newest ~200 public ready videos excluding the one
|
||||
// being watched. 200 is big enough for the blend to find real
|
||||
// similarities, small enough to score in-process without a query
|
||||
// per candidate.
|
||||
$recommendedVideos = (function () use ($video) {
|
||||
$candidates = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('id', '!=', $video->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->with('user')
|
||||
->latest('id')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
$ownTags = collect(explode(',', (string) ($video->tags ?? '')))
|
||||
->map(fn ($t) => mb_strtolower(trim($t)))
|
||||
->filter()
|
||||
->unique();
|
||||
|
||||
$scored = $candidates->map(function (Video $cand) use ($video, $ownTags) {
|
||||
$score = 0;
|
||||
if ($cand->type === $video->type) $score += 4;
|
||||
if ($video->language && $cand->language === $video->language) $score += 3;
|
||||
if ($ownTags->isNotEmpty()) {
|
||||
$candTags = collect(explode(',', (string) ($cand->tags ?? '')))
|
||||
->map(fn ($t) => mb_strtolower(trim($t)))
|
||||
->filter();
|
||||
if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2;
|
||||
}
|
||||
if ($cand->user_id === $video->user_id) $score += 1;
|
||||
|
||||
$cand->_rec_score = $score;
|
||||
return $cand;
|
||||
});
|
||||
|
||||
// Sort by score desc, then newest. Keep up to 20.
|
||||
return $scored
|
||||
->sortBy([
|
||||
['_rec_score', 'desc'],
|
||||
['created_at', 'desc'],
|
||||
])
|
||||
->take(20)
|
||||
->values();
|
||||
})();
|
||||
|
||||
$view = match ($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
@@ -828,7 +961,15 @@ class VideoController extends Controller
|
||||
: $slideMap['0'];
|
||||
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
// Dedupe the language picker by (language + label). A video with two
|
||||
// tracks sharing the same language (e.g. "ar" uploaded twice while
|
||||
// retrying) used to list both and leave the viewer guessing. Keeping
|
||||
// the first occurrence preserves the primary when it exists; using
|
||||
// (lang, label) as the key still lets legit variants coexist (e.g.
|
||||
// "en — Original" and "en — Director's cut").
|
||||
$audioTracks = $video->audioTracks
|
||||
->unique(fn ($t) => mb_strtolower((string) $t->language) . '|' . mb_strtolower((string) ($t->label ?? '')))
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'language' => $t->language,
|
||||
'label' => $t->label,
|
||||
@@ -913,6 +1054,53 @@ class VideoController extends Controller
|
||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||
* so the next Generate produces a fresh result.
|
||||
*/
|
||||
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$html = (string) $video->description;
|
||||
if (trim($html) === '') {
|
||||
return response()->json(['error' => 'Nothing to translate'], 400);
|
||||
}
|
||||
// Flatten the rich description → plain text, keeping paragraph breaks
|
||||
// so the client can render original and translation line-by-line.
|
||||
$plain = trim(html_entity_decode(strip_tags(
|
||||
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
|
||||
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$lines = preg_split('/\R/u', $plain);
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Source language inferred from the owner's access-history country,
|
||||
// falling back to users.nationality.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
|
||||
$sourceCountry = $topCountries[$video->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
|
||||
if (! $translatedLines || count($translatedLines) !== count($lines)) {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
foreach ($lines as $i => $src) {
|
||||
$segments[] = [
|
||||
'original' => (string) $src,
|
||||
'translated' => (string) ($translatedLines[$i] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'segments' => $segments,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country' => $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
@@ -1951,47 +2139,52 @@ class VideoController extends Controller
|
||||
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$hours = $request->get('hours', 48);
|
||||
$limit = $request->get('limit', 50);
|
||||
|
||||
$hours = min(max($hours, 24), 168);
|
||||
$limit = min(max($limit, 10), 100);
|
||||
// Snap the ?hours param to one of the three windows the UI exposes
|
||||
// (Today / This Week / This Month). The old clamp (24..168) silently
|
||||
// capped the "month" tab at a week, which is why the tabs all showed
|
||||
// the same data; now 720 is a first-class option.
|
||||
$hours = (int) $request->get('hours', 24);
|
||||
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
|
||||
$limit = min(max((int) $request->get('limit', 50), 10), 100);
|
||||
|
||||
// Candidate pool: public ready videos created at any time (not just
|
||||
// the last 10 days) — a video uploaded three weeks ago still belongs
|
||||
// in "This Month" if it caught fire this week. We rank by the number
|
||||
// of views INSIDE the window, with the recency bonus/velocity now
|
||||
// used only as tiebreakers.
|
||||
$windowStart = now()->subHours($hours);
|
||||
$videos = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->where('created_at', '>=', now()->subMonths(6))
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$videos = $videos->map(function ($video) use ($hours) {
|
||||
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
|
||||
$recentViews = \DB::table('video_views')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->where('watched_at', '>=', $windowStart)
|
||||
->count();
|
||||
|
||||
$likeCount = \DB::table('video_likes')
|
||||
->where('video_id', $video->id)
|
||||
->count();
|
||||
|
||||
$ageHours = $video->created_at->diffInHours(now());
|
||||
$velocity = $recentViews / $hours;
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||||
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeCount * 0.1 * 0.05);
|
||||
|
||||
$video->trending_score = round($score, 2);
|
||||
$video->view_count = $recentViews;
|
||||
$video->like_count = $likeCount;
|
||||
|
||||
$video->_tiebreak_age = $video->created_at->timestamp;
|
||||
return $video;
|
||||
});
|
||||
|
||||
// Rank: in-window views (primary), likes (tiebreak #1), newest
|
||||
// created_at (tiebreak #2). Any video with zero views in the window
|
||||
// is dropped so the list never surfaces truly dormant content.
|
||||
$trendingVideos = $videos
|
||||
->filter(fn ($v) => $v->trending_score > 0)
|
||||
->sortByDesc('trending_score')
|
||||
->filter(fn ($v) => $v->view_count > 0)
|
||||
->sortBy([
|
||||
['view_count', 'desc'],
|
||||
['like_count', 'desc'],
|
||||
['_tiebreak_age', 'desc'],
|
||||
])
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
@@ -2002,7 +2195,7 @@ class VideoController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function shorts(Request $request)
|
||||
public function shorts(Request $request, ?int $startId = null)
|
||||
{
|
||||
$videos = Video::public()
|
||||
->where('is_shorts', true)
|
||||
@@ -2011,7 +2204,23 @@ class VideoController extends Controller
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('videos.shorts', compact('videos'));
|
||||
return view('videos.shorts', [
|
||||
'videos' => $videos,
|
||||
'startId' => $startId, // the id to auto-scroll to, if any
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* /shorts/{video}: deep-link to a specific short in the vertical feed.
|
||||
* The feed itself is the same ordered list; the view uses ?start= / the
|
||||
* $startId prop to scroll into position on load.
|
||||
*/
|
||||
public function shortsFocused(Video $video)
|
||||
{
|
||||
if (! $video->is_shorts || ! $video->canView(Auth::user())) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->shorts(request(), $video->id);
|
||||
}
|
||||
|
||||
public function stream(Video $video)
|
||||
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'notification_preferences' => 'array',
|
||||
// TAKEONE account link. The token is a live credential for that
|
||||
// platform, so it is encrypted at rest and never sent back to a browser.
|
||||
'takeone_token' => 'encrypted',
|
||||
'takeone_connected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Can this user run TAKEONE lookups?
|
||||
*
|
||||
* A super admin always can: the platform holds a TAKEONE super-admin token
|
||||
* on their behalf, so they are connected with full reach without ever
|
||||
* pasting anything. Everyone else links their own account, and sees only
|
||||
* what that account may see.
|
||||
*/
|
||||
public function takeoneLinked(): bool
|
||||
{
|
||||
return $this->takeoneToken() !== null;
|
||||
}
|
||||
|
||||
/** True when the connection is the platform's, not one this user pasted. */
|
||||
public function takeoneManagedLink(): bool
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
return ($own === null || $own === '')
|
||||
&& $this->isSuperAdmin()
|
||||
&& ! empty(config('services.takeone.admin_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to talk to TAKEONE with. A personally linked account always
|
||||
* wins, so a super admin who deliberately links their own identity is not
|
||||
* silently upgraded back to the platform credential.
|
||||
*/
|
||||
public function takeoneToken(): ?string
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
if ($own !== null && $own !== '') {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if ($this->isSuperAdmin()) {
|
||||
$platform = config('services.takeone.admin_token');
|
||||
|
||||
return ! empty($platform) ? $platform : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defaults for each preference key (true = on, false = off)
|
||||
private const NOTIF_DEFAULTS = [
|
||||
'notif_new_comment' => true,
|
||||
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-frequent ISO2 country for each user id, aggregating
|
||||
* video_views + profile_visits and falling back to users.nationality.
|
||||
* Returns [user_id => 'BH', …]; keys without data are omitted.
|
||||
*/
|
||||
public static function topCountriesFor(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
|
||||
if (! $userIds) return [];
|
||||
|
||||
$tally = [];
|
||||
$rows = DB::table('video_views')
|
||||
->selectRaw('user_id, country, COUNT(*) as c')
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
if (Schema::hasTable('profile_visits')) {
|
||||
$rows = DB::table('profile_visits')
|
||||
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
|
||||
->whereIn('visitor_user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('visitor_user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tally as $uid => $counts) {
|
||||
arsort($counts);
|
||||
$out[$uid] = array_key_first($counts);
|
||||
}
|
||||
|
||||
// Fallback to users.nationality for anyone still unresolved.
|
||||
$missing = array_values(array_diff($userIds, array_keys($out)));
|
||||
if ($missing) {
|
||||
$nats = static::whereIn('id', $missing)
|
||||
->whereNotNull('nationality')->where('nationality', '!=', '')
|
||||
->pluck('nationality', 'id');
|
||||
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
// Mail failures must never bubble up as a 500 to the user — the account
|
||||
// exists, the HTTP request was fine, only the delivery pipe is broken.
|
||||
// We log the failure (ops picks it up) and move on. Re-sending via
|
||||
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
|
||||
try {
|
||||
$this->notify(new VerifyEmail);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
|
||||
'user_id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Fires to every super admin when the same email or IP has produced several
|
||||
* failed login attempts in a short window. Lets ops decide whether to lock
|
||||
* the account, change the password, or just raise the global throttle.
|
||||
*/
|
||||
class BruteForceLoginAlert extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $email,
|
||||
public readonly string $ip,
|
||||
public readonly int $failures,
|
||||
public readonly int $windowMinutes,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'kind' => 'brute_force_login_alert',
|
||||
'email' => $this->email,
|
||||
'ip' => $this->ip,
|
||||
'failures' => $this->failures,
|
||||
'window_minutes' => $this->windowMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('TAKEONE — suspicious login attempts')
|
||||
->greeting('Possible brute-force login attempt')
|
||||
->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.")
|
||||
->line("Most recent attempt came from {$this->ip}.")
|
||||
->line('Review the audit log and consider forcing a password reset if this account looks real.')
|
||||
->action('Open Admin', url('/admin/audit-logs'));
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
|
||||
);
|
||||
|
||||
// Force HTTPS
|
||||
// Force HTTPS unless we're running under phpunit — the test client
|
||||
// speaks plain http://localhost; forcing https would make every POST
|
||||
// in a feature test resolve to a 302 instead of hitting the controller.
|
||||
if (! $this->app->environment('testing')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Blocks impersonation of staff/system accounts.
|
||||
*
|
||||
* Applied to both `name` and the email local-part at registration so a user
|
||||
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
|
||||
* let them pass themselves off as the platform or its operators to other
|
||||
* users (comments, DMs, channel pages).
|
||||
*/
|
||||
class ReservedIdentifier implements ValidationRule
|
||||
{
|
||||
private const RESERVED = [
|
||||
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
|
||||
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
|
||||
'moderator', 'mod', 'staff', 'official',
|
||||
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
|
||||
'security', 'abuse', 'billing',
|
||||
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
|
||||
'play', 'playbh', 'playtakeone',
|
||||
'anonymous', 'guest', 'null', 'undefined',
|
||||
];
|
||||
|
||||
public function __construct(private readonly string $kind = 'name')
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = $this->kind === 'email'
|
||||
? strtolower(trim(strstr($value, '@', true) ?: $value))
|
||||
: strtolower(trim($value));
|
||||
|
||||
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
|
||||
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
|
||||
|
||||
foreach (self::RESERVED as $word) {
|
||||
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
|
||||
if ($normalized === $target) {
|
||||
$fail($this->kind === 'email'
|
||||
? 'That email address is reserved. Please use a different one.'
|
||||
: 'That name is reserved. Please choose a different one.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,16 +191,176 @@ class LlmLyricsService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate arbitrary text (e.g. a user comment) to a target language.
|
||||
* Returns the translated string, or '' on any failure / when disabled.
|
||||
*/
|
||||
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! $this->isEnabled()) return '';
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') return $cached;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
|
||||
. " - Translate EVERY word of the source language, including short exclamations,\n"
|
||||
. " repeated words, and words that happen to also look like a name in the target\n"
|
||||
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
|
||||
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
|
||||
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
|
||||
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
|
||||
. " it is almost certainly a regular word — translate it.\n"
|
||||
. " - If the text is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
|
||||
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
|
||||
. " - Keep the same tone and line breaks.\n\n"
|
||||
. "TEXT:\n" . $text;
|
||||
|
||||
// Translation is deterministic — temp 0. If the model echoes the input
|
||||
// (common when a word happens to also exist as a name in the target
|
||||
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
|
||||
// with a stronger directive. If the retry also echoes, we return it.
|
||||
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
|
||||
// keeps the model from drifting into long completions on tiny inputs.
|
||||
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
|
||||
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
|
||||
$needsRetry = $sourceLangHint
|
||||
&& $sourceLangHint !== $targetLang
|
||||
&& self::looksLikeEcho($text, $raw);
|
||||
if ($needsRetry) {
|
||||
$retryPrompt = $prompt
|
||||
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
|
||||
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
|
||||
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
|
||||
}
|
||||
if ($raw === '') return '';
|
||||
|
||||
Cache::put($cacheKey, $raw, now()->addDays(30));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function postProcess(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
|
||||
private static function looksLikeEcho(string $src, string $out): bool
|
||||
{
|
||||
if ($out === '') return false;
|
||||
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||
$a = $norm($src); $b = $norm($out);
|
||||
return $a !== '' && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a sequence of lines, preserving the one-to-one mapping so the
|
||||
* caller can render them side-by-side with the originals. Empty entries
|
||||
* are kept as empty strings. Returns an array of the same length as $lines
|
||||
* (or [] on any failure / when disabled).
|
||||
*/
|
||||
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
|
||||
{
|
||||
if (! $lines || ! $this->isEnabled()) return [];
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && count($cached) === count($lines)) return $cached;
|
||||
|
||||
// Index non-empty lines so empty separators are preserved verbatim.
|
||||
$toTranslate = [];
|
||||
foreach ($lines as $i => $l) {
|
||||
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
|
||||
}
|
||||
if (! $toTranslate) {
|
||||
Cache::put($cacheKey, $lines, now()->addDays(30));
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$numbered = [];
|
||||
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
|
||||
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
|
||||
. " - Translate every word including short exclamations and repeated words that happen\n"
|
||||
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
|
||||
. " - If a line is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
|
||||
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
|
||||
. "LINES (index TAB text):\n" . implode("\n", $numbered);
|
||||
|
||||
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
|
||||
$obj = json_decode($raw, true);
|
||||
if (! is_array($obj)) return [];
|
||||
|
||||
$out = $lines;
|
||||
$echoCount = 0;
|
||||
$translatedCount = 0;
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$out[$i] = $v;
|
||||
$translatedCount++;
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
|
||||
$echoCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If > 30% of the translated lines just echo the source, nudge the model.
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang
|
||||
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
|
||||
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
|
||||
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
|
||||
$obj2 = json_decode($raw2, true);
|
||||
if (is_array($obj2)) {
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
|
||||
$out[$i] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, $out, now()->addDays(30));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Dispatch to the active provider's adapter. */
|
||||
private function call(string $prompt, int $maxTokens): string
|
||||
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$p = $this->activeProvider();
|
||||
if (! $p) return '';
|
||||
try {
|
||||
return match ($p['kind']) {
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens),
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
||||
@@ -208,24 +368,37 @@ class LlmLyricsService
|
||||
}
|
||||
}
|
||||
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
|
||||
// Qwen3-style "thinking" models respect /no_think to skip the hidden
|
||||
// reasoning phase. Without it they can exhaust num_predict inside
|
||||
// the thinking block and emit empty content.
|
||||
$taggedPrompt = $prompt . "\n\n/no_think";
|
||||
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||
'stream' => false,
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
|
||||
'think' => false,
|
||||
// Keep the model resident for an hour so follow-up requests skip
|
||||
// the cold-start reload (default is 5 minutes). Dramatically
|
||||
// reduces per-click latency for comment/description translation.
|
||||
'keep_alive' => '1h',
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
return '';
|
||||
}
|
||||
$j = $resp->json();
|
||||
return (string) ($j['message']['content'] ?? '');
|
||||
$content = (string) ($j['message']['content'] ?? '');
|
||||
// Some thinking models still ignore the flag and leak <think>…</think>
|
||||
// tags into content. Strip them.
|
||||
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
|
||||
$resp = Http::timeout(120)->withHeaders([
|
||||
@@ -235,6 +408,7 @@ class LlmLyricsService
|
||||
])->post($endpoint . '/v1/messages', [
|
||||
'model' => $p['model'],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
@@ -245,7 +419,7 @@ class LlmLyricsService
|
||||
return (string) ($j['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
|
||||
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
||||
@@ -254,7 +428,7 @@ class LlmLyricsService
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => 0.2,
|
||||
'temperature' => $temperature,
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
|
||||
@@ -90,9 +90,15 @@ class VideoMergeService
|
||||
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||
|
||||
// ── 1. Promote source's primary audio to a new track on target ─
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3';
|
||||
// Layout-aware: the source may be stored either as the modern
|
||||
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
|
||||
// or as the legacy flat layout ({sourceDir}/audio.ext,
|
||||
// {sourceDir}/slides/, …). We detect which from source->path
|
||||
// and move only what actually exists in each case.
|
||||
$srcPrimaryPath = (string) $source->path;
|
||||
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
|
||||
|
||||
$newPrimaryTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
@@ -108,43 +114,83 @@ class VideoMergeService
|
||||
}
|
||||
|
||||
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
|
||||
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder);
|
||||
// Case A: primary audio sits in a dedicated per-track folder → one
|
||||
// folder move brings the audio, its slides and its thumb along.
|
||||
// Case B: primary audio sits directly at the video root (legacy) →
|
||||
// move the individual files, since the video root also holds the
|
||||
// extra tracks' subfolders which stay put until step 2.
|
||||
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
|
||||
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
|
||||
} else {
|
||||
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
|
||||
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
|
||||
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
|
||||
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
|
||||
}
|
||||
// Opportunistically move thumb.{ext} if present.
|
||||
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
|
||||
$thumb = "{$sourceDir}/thumb.{$ext}";
|
||||
if (file_exists(storage_path('app/' . $thumb))) {
|
||||
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
|
||||
}
|
||||
}
|
||||
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
|
||||
// The new per-track layout stores lyrics at
|
||||
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
|
||||
// on the target resolves it immediately.
|
||||
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
|
||||
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
|
||||
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
|
||||
|
||||
// ── 2. Move each source extra track's folder into target/tracks/
|
||||
// The folder basename ({lang}-{track_id}) is globally unique because
|
||||
// track ids are globally unique — no collisions with target's own tracks.
|
||||
// The folder basename ({lang}-{track_id}) is globally unique
|
||||
// because track ids are globally unique — no collisions with
|
||||
// target's own tracks. We derive the actual old folder from
|
||||
// the track's stored path so unusual layouts still work.
|
||||
foreach ($source->audioTracks as $track) {
|
||||
$basename = $this->nas->trackFolderName($source, $track);
|
||||
$oldFolder = "{$sourceDir}/tracks/{$basename}";
|
||||
$newFolder = "{$targetDir}/tracks/{$basename}";
|
||||
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
if ($oldFolder !== $newFolder) {
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
}
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: $track->path;
|
||||
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$track->save();
|
||||
|
||||
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
|
||||
// In the new layout they belong inside the moved track folder.
|
||||
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
|
||||
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
|
||||
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Move ALL slides for the source video ────────────────────
|
||||
// One pass: re-parent every source slide, rewrite any filename
|
||||
// that started with sourceDir/... to targetDir/..., and if the
|
||||
// slide had NULL audio_track_id (belonged to source's primary),
|
||||
// point it at the newly-created primary track.
|
||||
$sourcePrefix = $sourceDir . '/';
|
||||
$targetPrefix = $targetDir . '/';
|
||||
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||
// location, derived from the track it belongs to AFTER the
|
||||
// per-track folder moves above. This is layout-agnostic —
|
||||
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$newFilename = $s->filename;
|
||||
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
|
||||
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
|
||||
}
|
||||
$base = $s->filename ? basename($s->filename) : null;
|
||||
if (!$base) continue; // nothing we can safely rewrite
|
||||
|
||||
$owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
|
||||
$owningTrack = $updatedTracks->get($owningTrackId);
|
||||
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
|
||||
|
||||
$updates = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => $newFilename,
|
||||
'filename' => "{$owningFolder}/slides/{$base}",
|
||||
];
|
||||
if ($s->audio_track_id === null) {
|
||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||
@@ -197,12 +243,54 @@ class VideoMergeService
|
||||
});
|
||||
|
||||
// ── 10. Best-effort cleanup of the emptied source folder ───────────
|
||||
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
|
||||
// rmdir on sourceDir can succeed.
|
||||
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
|
||||
$this->pruneEmptyFolder("{$sourceDir}/tracks");
|
||||
$this->pruneEmptyFolder($sourceDir);
|
||||
|
||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an individual file (not a directory) on NAS + local. Needed for
|
||||
* legacy-layout sources where the primary audio and thumb live directly
|
||||
* in the video root rather than inside a per-track subfolder.
|
||||
*/
|
||||
private function moveFile(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->mkdirp(dirname($newRel));
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'file',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_file($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFile', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a folder on both NAS and local disk. NAS ops are queued in
|
||||
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
|
||||
|
||||
@@ -77,6 +77,11 @@ class HtmlSanitizer
|
||||
/**
|
||||
* Produce safe display HTML. Legacy plain-text values (no tags) are
|
||||
* escaped and converted to <br>; rich values are run through clean().
|
||||
*
|
||||
* Also detects a handful of common markdown-ish prefixes that leaked
|
||||
* in through AI-generated descriptions ("## Letra", "### Chorus") and
|
||||
* turns them into real <h2>/<h3> tags before sanitising. Without this
|
||||
* step a reader saw literal "## Letra" instead of a heading.
|
||||
*/
|
||||
public static function render(?string $value): string
|
||||
{
|
||||
@@ -85,6 +90,8 @@ class HtmlSanitizer
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = self::promoteLeakedMarkdown($value);
|
||||
|
||||
if (strip_tags($value) === $value) {
|
||||
return nl2br(e($value), false);
|
||||
}
|
||||
@@ -92,6 +99,24 @@ class HtmlSanitizer
|
||||
return self::clean($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown-style heading prefixes at the start of a line into
|
||||
* real HTML tags, so the sanitiser can keep them. Only touches lines
|
||||
* that start with ##, ###, or **…**; everything else is untouched.
|
||||
*/
|
||||
private static function promoteLeakedMarkdown(string $value): string
|
||||
{
|
||||
$lines = preg_split("/\r?\n/", $value);
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
|
||||
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private static function sanitizeChildren(DOMNode $node): void
|
||||
{
|
||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
|
||||
* crash a user-facing request. Everywhere we previously did
|
||||
*
|
||||
* $user->notify(new SomeNotification(...));
|
||||
*
|
||||
* we now do
|
||||
*
|
||||
* SafeNotifier::send($user, new SomeNotification(...));
|
||||
*
|
||||
* which runs the same code, catches any Throwable, and writes a warning line
|
||||
* so ops still has the signal.
|
||||
*/
|
||||
class SafeNotifier
|
||||
{
|
||||
/**
|
||||
* @param mixed $notifiable Anything with ->notify(), usually a User.
|
||||
*/
|
||||
public static function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$notifiable->notify($notification);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('notification send failed', [
|
||||
'notification' => $notification::class,
|
||||
'notifiable' => $notifiable::class,
|
||||
'notifiable_id'=> $notifiable->getKey() ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# YouTube-parity audit remediation plan
|
||||
|
||||
Branch per phase. Feature tests per fix. STOP for approval between phases.
|
||||
|
||||
**Safety tag:** `backup/pre-audit-2026-10-21` at commit `5058eaf` on `full-screen-data`.
|
||||
**Stashed WIP:** `stash@{0}` — pre-audit WIP on `full-screen-data` (recover with `git stash pop` on that branch).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Security & data integrity
|
||||
Branch: `fix/phase-1-security`
|
||||
|
||||
- [ ] Wrap `sendEmailVerificationNotification` + `NewCommentNotification` mail in try/catch; move to a queued notification channel; log failures. Document `.env` for Gmail app password + Mailgun/Postmark fallback.
|
||||
- [ ] `php artisan app:resend-verification {--user=}` command.
|
||||
- [ ] Reserved-name blocklist (`admin`, `superadmin`, `takeone`, `support`, `root`, …) via `App\Rules\ReservedIdentifier` wired into `RegisteredUserController`.
|
||||
- [ ] Harden login throttling to `5,1` with lockout message; log repeated failures and alert super admins.
|
||||
- [ ] Confirm `email_verified_at` is not mass-assignable; only the signed-URL verification route flips it.
|
||||
- [ ] Comment dedupe: raise 5s → 60s window, add client idempotency key header, server reject on duplicate. Disable submit button on first click.
|
||||
- [ ] `php artisan comments:dedupe --dry-run` cleanup command.
|
||||
- [ ] Owner-view exclusion in `VideoController@trackProgress`; dedupe by `device_hash` within 30 min.
|
||||
- [ ] Feature tests for each item.
|
||||
|
||||
## Phase 2 — Performance & DOM
|
||||
Branch: `fix/phase-2-perf`
|
||||
|
||||
- [ ] One shared `#globalEditModal` + `#globalCardMenu` per page; populated via `GET /videos/{id}/edit-payload`. Owner-only items gated server-side.
|
||||
- [ ] Lazy-render reply/edit boxes (template clone on open).
|
||||
- [ ] Match highlights panel rendered once.
|
||||
- [ ] `href="javascript:void(0)"` → real `<button>` elements. Hide "Not interested" / "Don't recommend" / "Report" on own videos.
|
||||
- [ ] Cursor pagination (`?cursor=`) across Home, `/videos` filters, channel Videos, search, history, liked, comments.
|
||||
- [ ] IntersectionObserver infinite scroll + "Load more" fallback.
|
||||
- [ ] `loading="lazy"`, explicit `width`/`height`, skeleton placeholders on thumbnails.
|
||||
|
||||
## Phase 3 — Feeds, search, trending
|
||||
Branch: `fix/phase-3-feeds`
|
||||
|
||||
- [ ] Music + filter feeds switch to `latest()`.
|
||||
- [ ] Trending windows: 24 / 168 / 720 h; rank by in-window views.
|
||||
- [ ] Channel Popular: order by views, tiebreak by likes.
|
||||
- [ ] Duration badge on card thumbnail. Scoreboard thumb → `<img>` or `aria-hidden`. Strip `CLUBLOGO` / `FLAG` placeholders.
|
||||
- [ ] Filter tabs: fetch + `pushState`, sticky, `aria-current`.
|
||||
- [ ] Normalize naming (DB stays `match`, display label "Sports" everywhere).
|
||||
- [ ] "New to You" → "Recent" (or make personalized).
|
||||
- [ ] New `SearchController` with `/search?q=` matching title/description/tags/language/channel; include channels + playlists. `/videos/search` becomes 302 redirect. Keep query in input. Merge the two duplicate search forms.
|
||||
- [ ] `/feed/subscriptions` route backed by `subscriptions` table.
|
||||
|
||||
## Phase 4 — Watch page
|
||||
Branch: `fix/phase-4-watch`
|
||||
|
||||
- [ ] Up Next: real `<a href>` cards. Recommender by type/tags/language, cross-channel. Tabs All / From this channel / Same type. Show more.
|
||||
- [ ] Unified player wrapper for match/music/generic: theater (t), mini (i), PiP, volume slider, captions=lyrics (c), 2x speed, end screen, autoplay countdown. Autoplay-ON forces Loop-OFF.
|
||||
- [ ] Like count visible, dislike button, Subscribe for guests, viewer `⋯` → Report. Channel row on match videos.
|
||||
- [ ] `/videos/{id}/download/{format}` signed route; enforce per-video Downloads setting (Off/Everyone/Members/Subscribers) server-side for MP4 **and** MP3.
|
||||
- [ ] Comments: `?lc=` permalink with scroll + highlight. Heart + pin (creator). "Read more" clamp. @handle prefill on nested reply. Singular/plural counts. Top-level vs reply counts separate.
|
||||
- [ ] Markdown rendered (or stripped) in descriptions.
|
||||
- [ ] Dedupe audio Language menu.
|
||||
- [ ] Hide "Generating lyrics…" overlay unless job active.
|
||||
- [ ] Edit form prefilled with real values (fix "Generic" / "Loading…" defaults).
|
||||
- [ ] Streaming: HLS exists (`GenerateHlsJob`, `/videos/{video}/hls/{file}`). Switch player to hls.js when `hls_path` set. Verify `-movflags +faststart` on mp4 encode in `CompressVideoJob`. Add `Cache-Control` + Range on stream route.
|
||||
|
||||
## Phase 5 — Shorts
|
||||
Branch: `fix/phase-5-shorts`
|
||||
|
||||
- [ ] Upload flag + auto-detect (vertical, ≤60s).
|
||||
- [ ] `/shorts/{id}` route + branded 404 at `resources/views/errors/404.blade.php`.
|
||||
- [ ] `/shorts` feed: 100dvh snap container, single reused `<video>` moved between slots, next 1–2 preloaded, URL via `history.replaceState`, wheel + arrows + swipe + ▲▼. Loop. Muted autoplay + "Tap to unmute"; unmuted state in `localStorage`.
|
||||
- [ ] Right action column (like/dislike/comments/share). Bottom overlay (avatar/@handle/Subscribe/title). Comments side panel desktop / bottom sheet mobile.
|
||||
- [ ] Home Shorts shelf. Channel Shorts tab (fix "My Shorts").
|
||||
|
||||
## Phase 6 — Channel / Studio / Live
|
||||
Branch: `fix/phase-6-channel-studio-live`
|
||||
|
||||
- [ ] `users.handle` migration (unique, nullable → backfill → required). `/@handle` routes. Tabs `/@handle/{videos,shorts,playlists,posts,about}`. "Preview as visitor" toggle. Header shows description + links. Age/gender/horoscope private by default. Pluralize "1 video"; drop stray "· 0.0" on playlist cards.
|
||||
- [ ] `/studio/*`: content table + bulk edit, per-video analytics, two-step upload (Details → Visibility: tags + playlist + schedule), file-size + duration + processing-status surfaced.
|
||||
- [ ] Live: ended state → "Stream ended" + duration + recording (never "Waiting"). Live chat. Like / share / subscribe on viewer page. Channel name link. Private option on `/live/new` matching upload wording.
|
||||
|
||||
## Phase 7 — Admin, a11y, consistency
|
||||
Branch: `fix/phase-7-admin-a11y`
|
||||
|
||||
- [ ] Rename "Live Videos" → "Ready". Storage counts NAS (via `NasSyncService`). Fix KPI WoW %. Strip HTML in description excerpts. Full titles with tooltip. Next/Last pagination on Audit Logs, Users, Videos. Error log viewer groups by stack trace in natural order.
|
||||
- [ ] A11y: `aria-label` on every icon button (menu, search, voice, avatar, ⋯, Like-active, comment menus, seek slider with value text). Skip-to-content link. One `<h1>` per page. `label[for]` on all inputs. Remove duplicate "Prefer not to say". Birth year 13+ cap.
|
||||
- [ ] Title helper `{page} | Play`. Brand: **Play** displayed; `TAKEONE` stays as `config('app.name')` per CLAUDE.md.
|
||||
- [ ] Cleanup commands (all `--dry-run` first): `comments:dedupe`, `live:cleanup-test`, `tracks:dedupe`.
|
||||
|
||||
## Final deliverables
|
||||
- Checklist: done / partial / skipped (with reason).
|
||||
- `.env` keys the user must set.
|
||||
- Deploy steps: migrations, queue restart, cache clear, asset build.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Mail setup
|
||||
|
||||
Mail failures are now non-fatal — a down SMTP will log a warning in
|
||||
`data/logs/laravel.log` but will NOT 500 the user. Still, delivery is a
|
||||
feature; here's how to fix the current Gmail 535 auth errors.
|
||||
|
||||
## Option A — Gmail App Password (quickest, free, ~500 sends/day)
|
||||
|
||||
1. On `[email protected]`:
|
||||
- Enable 2-Step Verification (Google Account → Security).
|
||||
- Generate an **App Password** (Security → App passwords → "Mail", "Other: TAKEONE").
|
||||
- Copy the 16-character password Google shows (no spaces).
|
||||
|
||||
2. `.env`:
|
||||
```env
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=platformtakeone@gmail.com
|
||||
MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # the app password, no quotes needed
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=platformtakeone@gmail.com
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
3. `php artisan config:clear && php artisan queue:restart`
|
||||
|
||||
4. Smoke test:
|
||||
```bash
|
||||
php artisan app:resend-verification --user=<id-of-an-unverified-user>
|
||||
```
|
||||
|
||||
## Option B — Mailgun (recommended for production)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=mailgun
|
||||
MAILGUN_DOMAIN=mg.takeone.bh
|
||||
MAILGUN_SECRET=key-...
|
||||
MAILGUN_ENDPOINT=api.eu.mailgun.net # or api.mailgun.net for US region
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Option C — Postmark (also fine)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=postmark
|
||||
POSTMARK_TOKEN=...
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Resend verification to users whose first email failed
|
||||
|
||||
```bash
|
||||
# One specific user
|
||||
php artisan app:resend-verification --user=42
|
||||
|
||||
# Everyone still unverified (dry-run first)
|
||||
php artisan app:resend-verification --all-unverified --dry-run
|
||||
php artisan app:resend-verification --all-unverified
|
||||
```
|
||||
@@ -292,13 +292,16 @@
|
||||
|
||||
{{-- Ready Videos --}}
|
||||
<div class="col-6 col-lg-4 col-xl-2">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
|
||||
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
|
||||
<div class="stat-label">Live Videos</div>
|
||||
{{-- "Ready" matches the DB status column; "Live Videos" was misleading
|
||||
— nothing streams live from this card, it just counts videos that
|
||||
finished processing successfully. --}}
|
||||
<div class="stat-label">Ready</div>
|
||||
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
||||
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
||||
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
||||
@@ -741,12 +744,21 @@ new Chart(document.getElementById('typeChart'), {
|
||||
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
(function() {
|
||||
const countryData = @json($viewsByCountry->map(fn($r) => [
|
||||
@php
|
||||
// Blade's @json() parser can't handle a multi-line arrow function with
|
||||
// a `[` array literal inside its body — the directive reads until the
|
||||
// closing paren and sees the inner `[` as an unmatched bracket. Build
|
||||
// the array in PHP first, then hand the plain variable to @json().
|
||||
$countryChartData = $viewsByCountry->map(function ($r) {
|
||||
return [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
'total' => $r->total,
|
||||
])->values());
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
(function() {
|
||||
const countryData = @json($countryChartData);
|
||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||
const values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
|
||||
{{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
|
||||
the year dropdown at (this year − 13). --}}
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
|
||||
:max-year="(int) date('Y') - 13" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -245,12 +245,15 @@
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
/* Hard-cap the info block to guarantee every card has the same total
|
||||
height (thumb + info). Overflow hidden so any accidental extra
|
||||
row can't push the card taller than its neighbours. */
|
||||
height: 76px;
|
||||
overflow: hidden;
|
||||
/* Reserve a floor for the info block so every card lands at the same
|
||||
height when titles are short. Two-line titles are allowed to grow
|
||||
the block vertically — overflow:visible so no row is hidden. */
|
||||
min-height: 76px;
|
||||
}
|
||||
/* On channel pages the avatar + channel-name row is suppressed by the
|
||||
component's hideChannelInfo prop; drop the floor so the card doesn't
|
||||
leave empty space where the row used to be. */
|
||||
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
|
||||
.yt-video-card .yt-video-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -284,22 +287,26 @@
|
||||
color: #fff;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
/* Single-line, no wrapping — overflow ellipses. */
|
||||
white-space: nowrap;
|
||||
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
|
||||
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
|
||||
break mid-word so they can't widen the card beyond its grid track.
|
||||
Line-clamp replaces the single-line ellipsis the old rule used. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-height: calc(1.3em * 2);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
/* Block-level with nowrap so ellipsis actually clips the excess.
|
||||
Inline-flex would let the children overflow past the parent. */
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||
the single-line layout. */
|
||||
@@ -446,305 +453,5 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Cute Edit Modal Styles */
|
||||
.cute-edit-modal {
|
||||
max-width: 380px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cute-edit-content {
|
||||
background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cute-edit-header {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cute-edit-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cute-edit-header h5 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close-cute {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-close-cute:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.cute-edit-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cute-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cute-form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cute-form-group label i {
|
||||
color: #ff6b8a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cute-input, .cute-textarea {
|
||||
width: 100%;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-input:focus, .cute-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b8a;
|
||||
box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15);
|
||||
}
|
||||
|
||||
.cute-input::placeholder, .cute-textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Type Options */
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cute-type-option, .cute-privacy-option {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cute-type-option input, .cute-privacy-option input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-type-option span, .cute-privacy-option span {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-type-option:hover span, .cute-privacy-option:hover span {
|
||||
border-color: #555;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.cute-type-option.active span, .cute-privacy-option.active span {
|
||||
background: rgba(255, 107, 138, 0.15);
|
||||
border-color: #ff6b8a;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
/* Shorts Toggle in Edit Modal */
|
||||
.cute-shorts-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cute-shorts-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #333;
|
||||
border-radius: 12px;
|
||||
transition: 0.3s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cute-shorts-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider {
|
||||
background-color: #e63030;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.cute-shorts-label {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Thumbnail Upload */
|
||||
.cute-thumbnail-upload {
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.cute-thumbnail-upload:hover {
|
||||
border-color: #ff6b8a;
|
||||
background: rgba(255, 107, 138, 0.05);
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview i {
|
||||
font-size: 24px;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.cute-status {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-status.success {
|
||||
display: block;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.cute-status.error {
|
||||
display: block;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.cute-edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cute-btn-cancel, .cute-btn-save {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cute-btn-cancel {
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.cute-btn-cancel:hover {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cute-btn-save {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3);
|
||||
}
|
||||
|
||||
.cute-btn-save:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4);
|
||||
}
|
||||
|
||||
.cute-btn-save:disabled {
|
||||
background: #444;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.cute-edit-modal {
|
||||
max-width: 320px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@@ -306,7 +306,7 @@
|
||||
@if($embedAudioInput)
|
||||
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
|
||||
onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@props(['video' => null, 'size' => 'medium'])
|
||||
@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
|
||||
|
||||
@php
|
||||
use App\Data\Languages;
|
||||
@@ -85,10 +85,14 @@ $sizeClasses = match($size) {
|
||||
<a href="{{ $showUrl }}">
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
@endif
|
||||
{{-- width/height let the browser reserve the card's thumb box
|
||||
BEFORE the image decodes, so a grid of 60 cards doesn't shift
|
||||
as each thumbnail finishes loading. The ratio is honored by
|
||||
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
|
||||
width="320" height="180"
|
||||
loading="lazy" decoding="async"
|
||||
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
@if($videoUrl)
|
||||
{{-- Match cards preload metadata so the first frame is ready the
|
||||
instant the user hovers (otherwise the browser fetches, buffers
|
||||
@@ -98,9 +102,13 @@ $sizeClasses = match($size) {
|
||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||
</video>
|
||||
@endif
|
||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||
@include('components.partials.scorebar-mini', ['video' => $video])
|
||||
@endif
|
||||
{{-- Match cards used to inline the full VS portrait + the live-scoring
|
||||
scorebar as thumbnail overlays. Those partials leaked fighter
|
||||
names, "AKA / AO", "ROUND 1", "0:00" and club text into the
|
||||
card DOM — showing stale 0-0 for finished matches, breaking
|
||||
the title layout at narrow widths, and inflating card HTML.
|
||||
The regular <img> above is the thumbnail now; the VS intro
|
||||
and live scorebar live on the full match player page only. --}}
|
||||
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
@@ -132,13 +140,15 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="yt-video-info">
|
||||
<div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
|
||||
@unless($hideChannelInfo)
|
||||
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||
@if($video && $video->user && $video->user->avatar_url)
|
||||
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
|
||||
@endif
|
||||
</a>
|
||||
@endunless
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ $showUrl }}">
|
||||
@@ -152,7 +162,7 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</a>
|
||||
</h3>
|
||||
@if($video && $video->user)
|
||||
@if($video && $video->user && ! $hideChannelInfo)
|
||||
<a href="{{ route('channel', $video->user->channel) }}"
|
||||
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
||||
@endif
|
||||
@@ -171,15 +181,15 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
@if($video)
|
||||
<div class="position-relative">
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
|
||||
<i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($isOwner)
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
||||
@@ -189,17 +199,17 @@ $sizeClasses = match($size) {
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@endif
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-list-nested"></i> Add to queue
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-clock"></i> Save to Watch later
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-bookmark"></i> Save to playlist
|
||||
</x-save-to-playlist-button>
|
||||
</li>
|
||||
@@ -212,133 +222,45 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
@if($video->isShareable())
|
||||
<li>
|
||||
<x-share-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-share-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</x-share-button>
|
||||
</li>
|
||||
@endif
|
||||
{{-- "Not interested", "Don't recommend channel" and "Report"
|
||||
only make sense for someone else's video. On your own, the
|
||||
Edit/Delete items above are what you want instead. --}}
|
||||
@unless($isOwner)
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-dash-circle"></i> Not interested
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<i class="bi bi-x-circle"></i> Don't recommend channel
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-flag"></i> Report
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
@endunless
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
||||
<!-- Cute Edit Video Modal -->
|
||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
||||
<div class="modal-content cute-edit-content">
|
||||
<div class="cute-edit-header">
|
||||
<span class="cute-edit-icon">✏️</span>
|
||||
<h5>Edit Video</h5>
|
||||
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
|
||||
</div>
|
||||
<div class="cute-edit-body">
|
||||
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- Title -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
||||
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-text-paragraph"></i> Description</label>
|
||||
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Video Type -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-collection-play"></i> Type</label>
|
||||
<div class="cute-type-options">
|
||||
<label class="cute-type-option active" data-type="generic">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<span>🎬 Generic</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="music">
|
||||
<input type="radio" name="type" value="music">
|
||||
<span>🎵 Music</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="match">
|
||||
<input type="radio" name="type" value="match">
|
||||
<span>🏆 Match</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Toggle -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
||||
<label class="cute-shorts-toggle">
|
||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
|
||||
<span class="cute-shorts-slider"></span>
|
||||
<span class="cute-shorts-label">Mark as Short</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
||||
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
|
||||
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
|
||||
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
|
||||
<i class="bi bi-camera"></i>
|
||||
<span>Click to change</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-shield-lock"></i> Privacy</label>
|
||||
<div class="cute-privacy-options">
|
||||
<label class="cute-privacy-option active" data-privacy="public">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<span>🌐 Public</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="unlisted">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
<span>🔗 Unlisted</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="private">
|
||||
<input type="radio" name="visibility" value="private">
|
||||
<span>🔒 Private</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="cute-edit-actions">
|
||||
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
|
||||
<button type="submit" class="cute-btn-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{--
|
||||
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||
--}}
|
||||
|
||||
@include('components.partials.card-styles')
|
||||
|
||||
@@ -393,155 +315,8 @@ function stopVideo(element) {
|
||||
element.classList.remove('audio-playing');
|
||||
}
|
||||
|
||||
// Edit Modal Functions
|
||||
let currentEditVideoId = null;
|
||||
|
||||
function openEditVideoModal(videoId) {
|
||||
currentEditVideoId = videoId;
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
||||
modal.show();
|
||||
|
||||
// Fetch video data
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const video = data.video;
|
||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
||||
|
||||
// Set type
|
||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
||||
typeOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.type === (video.type || 'generic')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set privacy
|
||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
||||
privacyOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.privacy === (video.visibility || 'public')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set shorts toggle
|
||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
||||
if (shortsCheckbox) {
|
||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
||||
}
|
||||
|
||||
// Clear status
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
statusEl.className = 'cute-status';
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditVideoModal(videoId) {
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modalEl = document.getElementById(modalId);
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// SPA navigation re-executes this once-protected script on every page swap.
|
||||
// Guard the document-level listeners so they don't stack up across navs.
|
||||
if (!window._videoCardListenersBound) {
|
||||
window._videoCardListenersBound = true;
|
||||
|
||||
// Type option click handlers
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.cute-type-option')) {
|
||||
const option = e.target.closest('.cute-type-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
if (e.target.closest('.cute-privacy-option')) {
|
||||
const option = e.target.closest('.cute-privacy-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Thumbnail preview
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const videoId = e.target.id.replace('edit-thumbnail-', '');
|
||||
const preview = document.getElementById('thumbnail-preview-' + videoId);
|
||||
preview.innerHTML = `<span>${file.name}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target;
|
||||
if (form.id && form.id.startsWith('edit-video-form-')) {
|
||||
e.preventDefault();
|
||||
const videoId = form.id.replace('edit-video-form-', '');
|
||||
const formData = new FormData(form);
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
const submitBtn = form.querySelector('.cute-btn-save');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusEl.className = 'cute-status success';
|
||||
statusEl.textContent = '✓ Saved successfully!';
|
||||
setTimeout(() => {
|
||||
closeEditVideoModal(videoId);
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.message || 'Update failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
statusEl.className = 'cute-status error';
|
||||
statusEl.textContent = '✗ ' + error.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} // end _videoCardListenersBound guard
|
||||
// Edit-modal open/close + its form handlers live in the singleton at
|
||||
// layouts/partials/edit-video-modal.blade.php (not here) — see the
|
||||
// removed-markup note above the dropdown for why.
|
||||
</script>
|
||||
@endonce
|
||||
@@ -4,8 +4,21 @@
|
||||
use App\Models\CommentLike;
|
||||
|
||||
$currentUserId = Auth::id() ?? 0;
|
||||
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
|
||||
// Flag per commenter: most-frequent access country, falling back to the
|
||||
// user's nationality. Batched so the comments list stays a single query.
|
||||
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
||||
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
||||
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
|
||||
$topLevelCount = $topComments->count();
|
||||
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
|
||||
|
||||
// Permalink: ?lc=<comment-id> scrolls to and highlights a specific
|
||||
// comment or reply so a shared link lands on the exact message. The
|
||||
// id is validated against the current video's comment ids so the
|
||||
// highlight only applies when the target really belongs here.
|
||||
$permalinkId = (int) request('lc', 0);
|
||||
|
||||
$allCommentIds = $topComments->pluck('id')
|
||||
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
|
||||
@@ -40,7 +53,17 @@
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="ytc-header">
|
||||
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span>
|
||||
{{-- Top-level count on the header; reply count surfaces inside each
|
||||
thread's "N replies" expander. "1 Comment" singular when there's
|
||||
exactly one — never "1 Comments". --}}
|
||||
<span class="ytc-count" id="ytcCount">
|
||||
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
|
||||
@if($replyCount > 0)
|
||||
<span class="ytc-count-replies" aria-label="reply count">
|
||||
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="ytc-sort-wrap" id="ytcSortWrap">
|
||||
<button class="ytc-sort-btn" id="ytcSortBtn">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
|
||||
@@ -89,11 +112,19 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
|
||||
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
|
||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
|
||||
title="Copy link to this comment"
|
||||
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
|
||||
</div>
|
||||
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
{{-- Edit form --}}
|
||||
@if($isOwn)
|
||||
@@ -120,6 +151,12 @@
|
||||
@auth
|
||||
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
||||
@endauth
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||
<span class="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($isOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn" title="More actions">
|
||||
@@ -139,20 +176,12 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Reply form --}}
|
||||
{{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
|
||||
first click; the ~13-line markup used to render for
|
||||
every top-level comment (so a 50-comment video shipped
|
||||
~650 lines of hidden form markup to every viewer). --}}
|
||||
@auth
|
||||
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody{{ $comment->id }}"
|
||||
placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
|
||||
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
|
||||
@endauth
|
||||
|
||||
{{-- Replies --}}
|
||||
@@ -172,10 +201,18 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
|
||||
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
|
||||
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
|
||||
title="Copy link to this reply"
|
||||
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
||||
@@ -197,6 +234,12 @@
|
||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||
</button>
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||
<span class="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn">
|
||||
@@ -270,6 +313,23 @@
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ytc-count-replies {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Comment permalink highlight — brief glow on the targeted row when a
|
||||
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
|
||||
.ytc-comment.ytc-highlight,
|
||||
.ytc-reply.ytc-highlight {
|
||||
animation: ytc-highlight-pulse 2.8s ease-out;
|
||||
}
|
||||
@keyframes ytc-highlight-pulse {
|
||||
0% { background-color: rgba(239, 68, 68, 0.18); }
|
||||
40% { background-color: rgba(239, 68, 68, 0.18); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.ytc-sort-wrap { position: relative; }
|
||||
.ytc-sort-btn {
|
||||
display: flex;
|
||||
@@ -440,7 +500,11 @@
|
||||
.ytc-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The time stamp is now an <a> permalink. Underline on hover so it's
|
||||
discoverable but doesn't scream "link" at rest. */
|
||||
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
|
||||
|
||||
/* ── Comment text ── */
|
||||
.ytc-text {
|
||||
@@ -498,6 +562,57 @@
|
||||
}
|
||||
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ── Translate ── */
|
||||
.ytc-translate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 18px;
|
||||
transition: background .15s, color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
|
||||
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
||||
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
|
||||
.ytc-translate-btn.active { color: #3ea6ff; }
|
||||
.ytc-flag {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ytc-translation {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(62,166,255,.08);
|
||||
border-left: 2px solid #3ea6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ytc-translation-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
/* ── Three-dot menu ── */
|
||||
.ytc-more-wrap {
|
||||
position: relative;
|
||||
@@ -695,9 +810,16 @@ const YTC = {
|
||||
videoId: '{{ $video->getRouteKey() }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
userId: {{ $currentUserId }},
|
||||
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
|
||||
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
|
||||
deleteId: null,
|
||||
toastTimer: null,
|
||||
};
|
||||
const YTC_TRANSLATE_SVG = '<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>';
|
||||
function targetLang() {
|
||||
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
|
||||
return l || 'en';
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
function esc(s) {
|
||||
@@ -904,12 +1026,101 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
// Dislike btn
|
||||
const db = e.target.closest('.ytc-dislike-btn');
|
||||
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
||||
|
||||
});
|
||||
|
||||
// ── Reply form ────────────────────────────────────────
|
||||
// Translate — bound at `document` level once, dispatches to the LATEST
|
||||
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
|
||||
// replace #ytcSection and re-run this script; the new IIFE overwrites
|
||||
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
|
||||
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
|
||||
if (!window.__ytcTranslateDelegated) {
|
||||
window.__ytcTranslateDelegated = true;
|
||||
document.addEventListener('click', e => {
|
||||
const tb = e.target.closest('._comment-translate-trigger');
|
||||
if (!tb) return;
|
||||
e.stopPropagation();
|
||||
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Translate ─────────────────────────────────────────
|
||||
async function toggleTranslate(btn) {
|
||||
const id = btn.dataset.commentId;
|
||||
const panel = document.getElementById('translation-' + id);
|
||||
const label = btn.querySelector('.ytc-translate-label');
|
||||
if (!panel) return;
|
||||
|
||||
// Toggle off if already shown.
|
||||
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
if (label) label.textContent = 'Translate';
|
||||
return;
|
||||
}
|
||||
// Already loaded — just reveal.
|
||||
if (panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
if (label) label.textContent = 'Translating…';
|
||||
try {
|
||||
const r = await fetch('/comments/' + id + '/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ target: targetLang() })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
||||
|
||||
panel.innerHTML = '';
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ytc-translation-meta';
|
||||
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
|
||||
const body = document.createElement('div');
|
||||
body.textContent = d.translated;
|
||||
panel.appendChild(meta);
|
||||
panel.appendChild(body);
|
||||
|
||||
panel.dataset.loaded = '1';
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
} catch (err) {
|
||||
toast(err.message || 'Translation failed', 'error');
|
||||
if (label) label.textContent = 'Translate';
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reply form (lazy-rendered from a <template>) ──────
|
||||
// Replaces the per-comment inline markup that used to live in the DOM
|
||||
// pre-hidden. First click on "Reply" clones the template into the comment's
|
||||
// .ytc-reply-slot and wires its IDs. Subsequent toggles just show/hide.
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) return;
|
||||
let form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) {
|
||||
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
|
||||
const tpl = document.getElementById('ytc-reply-form-tpl');
|
||||
if (!slot || !tpl) return;
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const root = frag.querySelector('.ytc-reply-form');
|
||||
const ta = frag.querySelector('.ytc-reply-textarea');
|
||||
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
|
||||
const submit = frag.querySelector('._comment-submit-reply-trigger');
|
||||
root.id = 'replyForm' + commentId;
|
||||
ta.id = 'replyBody' + commentId;
|
||||
cancel.dataset.commentId = commentId;
|
||||
submit.dataset.parentId = commentId;
|
||||
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
|
||||
slot.appendChild(frag);
|
||||
form = root;
|
||||
}
|
||||
const isOpen = form.classList.contains('open');
|
||||
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
|
||||
if (!isOpen) {
|
||||
@@ -970,16 +1181,26 @@ async function saveEdit(commentId) {
|
||||
|
||||
// ── Post comment ──────────────────────────────────────
|
||||
async function postComment() {
|
||||
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
|
||||
const ta = document.getElementById('ytcTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
postComment._busy = true;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Posting…';
|
||||
try {
|
||||
// Idempotency key: random per attempt. On retry the client reuses the
|
||||
// same key so the server returns the original comment instead of
|
||||
// writing a second one.
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1000,6 +1221,7 @@ async function postComment() {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
postComment._busy = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Comment';
|
||||
}
|
||||
@@ -1012,11 +1234,21 @@ async function postReply(vid, parentId) {
|
||||
const text = input.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
|
||||
if (btn) {
|
||||
if (btn.dataset.busy === '1') return; // guard against double-submit
|
||||
btn.dataset.busy = '1';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Posting…';
|
||||
}
|
||||
try {
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + vid + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1035,7 +1267,7 @@ async function postReply(vid, parentId) {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1365,12 @@ function buildCommentEl(c, isReply) {
|
||||
</div>` : '';
|
||||
|
||||
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
|
||||
const translateBtn = YTC.llmEnabled ? `
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
|
||||
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
|
||||
</button>` : '';
|
||||
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
|
||||
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
||||
@@ -1143,10 +1381,12 @@ function buildCommentEl(c, isReply) {
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
${flagEl}
|
||||
<span class="ytc-author">${name}</span>
|
||||
<span class="ytc-time">${time}</span>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
||||
${translationPanel}
|
||||
${editForm}
|
||||
<div class="ytc-actions">
|
||||
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
||||
@@ -1157,6 +1397,7 @@ function buildCommentEl(c, isReply) {
|
||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||
</button>
|
||||
${replyBtn}
|
||||
${translateBtn}
|
||||
${moreMenu}
|
||||
</div>
|
||||
${replyForm}
|
||||
@@ -1309,15 +1550,38 @@ const listObserver = new MutationObserver(() => {
|
||||
const list = document.getElementById('ytcList');
|
||||
if (list) listObserver.observe(list, { childList: true, subtree: true });
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
});
|
||||
if (document.readyState !== 'loading') {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
// ── Comment permalink (?lc=<id>) ─────────────────────
|
||||
// Scroll to #comment-<id> and briefly highlight the row so a shared link
|
||||
// lands on the exact message. The reply row is nested inside a .ytc-replies
|
||||
// expander by default — we auto-open the parent thread first so the target
|
||||
// is visible before we scroll.
|
||||
function openCommentPermalink(id) {
|
||||
if (!id) return;
|
||||
const target = document.getElementById('comment-' + id);
|
||||
if (!target) return;
|
||||
// Find and expand the parent replies section, if any.
|
||||
const repliesSection = target.closest('.ytc-replies-section');
|
||||
if (repliesSection) {
|
||||
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
|
||||
if (toggle && toggle.dataset.open !== '1') toggle.click();
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('ytc-highlight');
|
||||
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
function _ytcInit() {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
@if($permalinkId)
|
||||
openCommentPermalink({{ (int) $permalinkId }});
|
||||
@endif
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', _ytcInit);
|
||||
if (document.readyState !== 'loading') _ytcInit();
|
||||
|
||||
// ── Public API (for backward compat) ─────────────────
|
||||
window._comment = {
|
||||
@@ -1327,3 +1591,22 @@ window._comment = {
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@auth
|
||||
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
|
||||
clicks Reply on a given comment. Keeping it here (not in the layout)
|
||||
means the markup travels with the comments component — the only place
|
||||
that uses it. --}}
|
||||
<template id="ytc-reply-form-tpl">
|
||||
<div class="ytc-reply-form">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endauth
|
||||
@@ -0,0 +1,74 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Page not found | ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.err-404 {
|
||||
max-width: 560px;
|
||||
margin: 80px auto;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.err-404-emoji {
|
||||
font-size: 72px;
|
||||
line-height: 1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.err-404 h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.err-404 p {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.err-404-code {
|
||||
font-size: 11px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.err-404-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.err-404 { margin: 32px 12px; padding: 32px 16px; }
|
||||
.err-404-emoji { font-size: 56px; }
|
||||
.err-404 h1 { font-size: 22px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="err-404">
|
||||
<div class="err-404-emoji" aria-hidden="true">🎞️</div>
|
||||
<div class="err-404-code">Error 404</div>
|
||||
<h1>Can't find that one</h1>
|
||||
<p>
|
||||
The page or video you were looking for isn't here. It might have
|
||||
been removed, renamed, or the link might have a typo.
|
||||
</p>
|
||||
<div class="err-404-actions">
|
||||
<a href="{{ route('home') }}" class="action-btn action-btn-primary">
|
||||
<i class="bi bi-house-door-fill" aria-hidden="true"></i>
|
||||
<span>Back to Home</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.index') }}" class="action-btn">
|
||||
<i class="bi bi-grid-fill" aria-hidden="true"></i>
|
||||
<span>Browse Videos</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.shorts') }}" class="action-btn">
|
||||
<i class="bi bi-lightning-charge-fill" aria-hidden="true"></i>
|
||||
<span>Watch Shorts</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1218,6 +1218,20 @@
|
||||
<script src="{{ asset('fp.js') }}" async></script>
|
||||
</head>
|
||||
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
||||
{{-- Skip link: keyboard users land here first and can jump straight to
|
||||
the main content region, past the header + sidebar nav. Hidden
|
||||
off-screen until focused. --}}
|
||||
<a href="#main" class="skip-to-content">Skip to content</a>
|
||||
<style>
|
||||
.skip-to-content {
|
||||
position: absolute; top: -40px; left: 8px;
|
||||
padding: 8px 14px; z-index: 10000;
|
||||
background: #ef4444; color: #fff; border-radius: 0 0 8px 8px;
|
||||
text-decoration: none; font-weight: 600;
|
||||
transition: top .15s ease;
|
||||
}
|
||||
.skip-to-content:focus { top: 0; outline: 2px solid #fff; outline-offset: 2px; }
|
||||
</style>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
@@ -1237,10 +1251,11 @@
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="mobile-search-submit">
|
||||
<i class="bi bi-search"></i>
|
||||
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
|
||||
<label for="mobile-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1697,11 +1712,24 @@
|
||||
};
|
||||
|
||||
function loadNotifications() {
|
||||
// Switch the pre-rendered "No notifications" placeholder to a
|
||||
// spinner while the fetch is in flight, then let the response
|
||||
// handler replace it with the real list, an empty state, or
|
||||
// an error message. Previously the panel just said "Loading…"
|
||||
// forever if the fetch threw synchronously or returned non-JSON.
|
||||
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
|
||||
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { loaded = true; renderNotifications(data); })
|
||||
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
|
||||
.catch(function () {
|
||||
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
|
||||
});
|
||||
}
|
||||
// Expose for the inline Retry button above.
|
||||
window.loadNotifications = loadNotifications;
|
||||
|
||||
window.toggleNotifPanel = function () {
|
||||
var btn = document.getElementById('notifBtn');
|
||||
|
||||
@@ -178,22 +178,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}}
|
||||
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true">
|
||||
<x-track-editor-form
|
||||
prefix="__TPL__"
|
||||
:is-primary="false"
|
||||
language-name="__LANGNAME__"
|
||||
language-id="csd_v___TPL__"
|
||||
title-name="__TITLENAME__"
|
||||
title-id="edit-__TPL__-title"
|
||||
desc-name="__DESCNAME__"
|
||||
desc-id="edit-__TPL__-desc"
|
||||
:embed-audio-input="true"
|
||||
audio-input-name="__AUDIONAME__"
|
||||
video-file-input-id=""
|
||||
/>
|
||||
</div>
|
||||
{{-- Secondary tracks in this modal are cloned from the SHARED
|
||||
#um-track-form-template defined in layouts.partials.upload-modal. One
|
||||
template → identical UX in both the create (upload) modal and this modal. --}}
|
||||
|
||||
<style>
|
||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
||||
@@ -720,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
|
||||
function editHandleSlides(fileList, tid) {
|
||||
tid = tid || 't1';
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlides);
|
||||
const inp = document.getElementById('edit-' + tid + '-slides-input');
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -740,8 +729,12 @@ function _editSlidesCropLoadNext() {
|
||||
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
|
||||
const f = _editSlidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_edit === 'function') {
|
||||
window.tcPreload_slides_edit(f);
|
||||
// Open modal first so the cropper canvas has a real layout box when
|
||||
// initCropper measures. Preloading first (prior order) caused the
|
||||
// second batch to open with stale zero-sized measurements and a Save
|
||||
// button that silently did nothing.
|
||||
window.openCropperModal_slides_edit();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
|
||||
|
||||
function editHandleSlidesForTrack(tid, fileList) {
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
||||
const inp = document.getElementById('edit-slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
function editClearSlidesForTrack(e, tid) {
|
||||
@@ -885,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
|
||||
// ── Track popup ───────────────────────────────────────────────────────────────
|
||||
function editOpenTrackPopup(trackId) {
|
||||
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
|
||||
const form = document.getElementById('edit-tf-' + trackId);
|
||||
// Primary track form uses the legacy id (edit-tf-t1); extra tracks are
|
||||
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
|
||||
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
|
||||
if (form) form.style.display = 'block';
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
popup.style.opacity = '0';
|
||||
@@ -902,6 +899,10 @@ function editCloseTrackPopup() {
|
||||
}
|
||||
}
|
||||
|
||||
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
|
||||
// newly-added and existing-track rows) the inputs come from the SHARED
|
||||
// #um-track-form-template, so they use the shared ids track-lang-{id} /
|
||||
// track-title-{id}. One function reads whichever is present.
|
||||
function editUpdateTrackCard(trackId) {
|
||||
let langCode = '', title = '';
|
||||
if (trackId === 't1') {
|
||||
@@ -910,9 +911,8 @@ function editUpdateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e','');
|
||||
const li = document.getElementById('csd_v_e' + n);
|
||||
const ti = document.getElementById('edit-e' + n + '-title');
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
@@ -932,24 +932,26 @@ function editUpdateTrackCard(trackId) {
|
||||
// ── Extra track management ────────────────────────────────────────────────────
|
||||
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
|
||||
// "add new track" and "load existing track" go through here, so there is one
|
||||
// source of truth for the form markup. The caller picks the submission names:
|
||||
// - new track → extra_track_*[]
|
||||
// - existing track → track_*_updates[trackId]
|
||||
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
|
||||
const tpl = document.getElementById('edit-extra-track-tpl');
|
||||
if (!tpl) return null;
|
||||
const html = tpl.innerHTML
|
||||
.replaceAll('__TPL__', prefix)
|
||||
.replaceAll('__LANGNAME__', langName)
|
||||
.replaceAll('__TITLENAME__', titleName)
|
||||
.replaceAll('__DESCNAME__', descName)
|
||||
.replaceAll('__AUDIONAME__', audioName);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const formEl = tmp.querySelector('.um-track-form');
|
||||
if (!formEl) return null;
|
||||
formEl.style.display = 'none';
|
||||
document.getElementById('edit-tf-extra').appendChild(formEl);
|
||||
_editSlidesData[prefix] = [];
|
||||
if (window.CSD) new CSD('csd_' + prefix);
|
||||
return formEl;
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: prefix, langName, titleName, descName, audioName,
|
||||
});
|
||||
if (!frag) return null;
|
||||
const host = document.getElementById('edit-tf-extra');
|
||||
host.appendChild(frag);
|
||||
// _slidesData is the shared state used by the shared slides-zone handlers.
|
||||
// Mirror the handle under _editSlidesData so the edit submit code stays
|
||||
// oblivious to the migration (one array, two aliases).
|
||||
window._slidesData = window._slidesData || {};
|
||||
_slidesData[prefix] = [];
|
||||
_editSlidesData[prefix] = _slidesData[prefix];
|
||||
window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
|
||||
return document.getElementById('um-tf-' + prefix);
|
||||
}
|
||||
|
||||
function _editTrackCard(n, trackId, isExisting) {
|
||||
@@ -990,34 +992,47 @@ function _editAddExistingTrack(track) {
|
||||
'track_description_updates[' + track.id + ']',
|
||||
'track_file_updates[' + track.id + ']'
|
||||
);
|
||||
const titleEl = document.getElementById('edit-' + pfx + '-title');
|
||||
// Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
|
||||
const titleEl = document.getElementById('track-title-' + pfx);
|
||||
if (titleEl) titleEl.value = track.title || '';
|
||||
const descEl = document.getElementById('edit-' + pfx + '-desc');
|
||||
const descEl = document.getElementById('track-desc-' + pfx);
|
||||
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
|
||||
if (track.language && window.CSD) {
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
const wrap = document.getElementById('csd_' + pfx);
|
||||
const hi = document.getElementById('track-lang-' + pfx);
|
||||
const wrap = document.getElementById('lsd_track_' + pfx);
|
||||
if (hi) hi.value = track.language;
|
||||
if (wrap) _editSetLangCsd(wrap, track.language);
|
||||
}
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
editUpdateTrackCard(pfx);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
function _editRemoveTrackForms(n) {
|
||||
const pfx = 'e' + n;
|
||||
document.getElementById('edit-tc-' + pfx)?.remove();
|
||||
// Form id depends on which cloner built it: shared template → um-tf-{pfx},
|
||||
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
|
||||
document.getElementById('um-tf-' + pfx)?.remove();
|
||||
document.getElementById('edit-tf-' + pfx)?.remove();
|
||||
delete _editSlidesData[pfx];
|
||||
delete (window._slidesData || {})[pfx];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
_editRemoveTrackForms(n);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
_editRemoveTrackForms(n);
|
||||
}
|
||||
|
||||
function editAddExtraTrack() {
|
||||
const n = ++_editExtraCount;
|
||||
const pfx = 'e' + n;
|
||||
@@ -1029,23 +1044,10 @@ function editAddExtraTrack() {
|
||||
'extra_track_descriptions[]',
|
||||
'extra_track_files[]'
|
||||
);
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
_editUpdateTrackPositions();
|
||||
editOpenTrackPopup(pfx);
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
delete _editSlidesData['e'+n];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Wire primary language → update card
|
||||
;(function() {
|
||||
const li = document.getElementById('edit_primary_language');
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<!-- Header -->
|
||||
<header class="yt-header">
|
||||
<div class="yt-header-left">
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
|
||||
<i class="bi bi-list fs-5" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="yt-logo">
|
||||
{{-- Two responsive logos. The visible one at a given viewport
|
||||
carries the app name; its sibling is display:none and gets
|
||||
alt="" so a screen reader doesn't announce "Play Play". --}}
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-center d-none d-md-flex">
|
||||
<div class="yt-search">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
|
||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
|
||||
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
|
||||
<label for="header-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-right">
|
||||
<!-- Mobile search -->
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
|
||||
<i class="bi bi-search"></i>
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@auth
|
||||
@@ -48,7 +52,7 @@
|
||||
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
||||
</div>
|
||||
<div class="yt-notif-list" id="notifList">
|
||||
<div class="yt-notif-empty">Loading…</div>
|
||||
<div class="yt-notif-empty" id="notifInitialState">No notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,9 +60,10 @@
|
||||
<!-- User dropdown -->
|
||||
<div class="yt-user-dropdown" id="headerUserDropdown">
|
||||
<button class="yt-icon-btn" id="headerUserBtn"
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true">
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"
|
||||
aria-label="Account menu for {{ Auth::user()->name }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}"
|
||||
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
|
||||
class="yt-user-avatar" alt="">
|
||||
</button>
|
||||
|
||||
<div class="yt-user-panel" id="headerUserPanel" role="menu">
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<a href="{{ route('subscriptions.feed') }}"
|
||||
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>Subscriptions</span>
|
||||
</a>
|
||||
|
||||
@@ -338,9 +338,31 @@
|
||||
</div>
|
||||
</div>{{-- /um-tf-t1 --}}
|
||||
|
||||
{{-- Extra track forms appended by JS --}}
|
||||
{{-- Extra track forms appended by JS (cloned from the template below) --}}
|
||||
<div id="um-tf-extra"></div>
|
||||
|
||||
{{-- SHARED track form template.
|
||||
This is the single source of truth for the "Add Language Track"
|
||||
dialog used by BOTH the create (upload) modal AND the edit modal.
|
||||
Any CSS/markup/behaviour change here automatically applies to both.
|
||||
Cloners replace the placeholders at clone time:
|
||||
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
|
||||
__LANGNAME__ → form field name for the language select
|
||||
__TITLENAME__ → form field name for the title input
|
||||
__DESCNAME__ → form field name for the description textarea
|
||||
__AUDIONAME__ → form field name for the audio file input --}}
|
||||
<template id="um-track-form-template">
|
||||
@include('layouts.partials.upload-modal.track-form', [
|
||||
'key' => '__N__',
|
||||
'showPrimaryNote' => false,
|
||||
'langName' => '__LANGNAME__',
|
||||
'titleName' => '__TITLENAME__',
|
||||
'descName' => '__DESCNAME__',
|
||||
'audioName' => '__AUDIONAME__',
|
||||
'langRequired' => true,
|
||||
])
|
||||
</template>
|
||||
|
||||
</div>{{-- /um-tp-body --}}
|
||||
<div class="um-tp-footer">
|
||||
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
|
||||
@@ -1372,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
|
||||
|
||||
function handleSlidesForTrack(tid, fileList) {
|
||||
_slidesCropStart(fileList, tid);
|
||||
// Reset the file input so picking the SAME filename a second time still
|
||||
// fires change (browsers suppress change when FileList is identical).
|
||||
const inp = document.getElementById('slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -1390,8 +1416,12 @@ function _slidesCropLoadNext() {
|
||||
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
|
||||
const f = _slidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_upload === 'function') {
|
||||
window.tcPreload_slides_upload(f);
|
||||
// Open the modal FIRST so the canvas has a non-zero layout box when
|
||||
// initCropper measures clientWidth/clientHeight. Preloading first
|
||||
// (the previous order) caused the second batch to open with stale
|
||||
// zero-sized measurements and a Save button that silently did nothing.
|
||||
window.openCropperModal_slides_upload();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,7 +1430,13 @@ function uploadSlidesCropDone(file) {
|
||||
if (tid && file) {
|
||||
if (!_slidesData[tid]) _slidesData[tid] = [];
|
||||
if (_slidesData[tid].length < 10) {
|
||||
_slidesData[tid].push(file);
|
||||
// Cache a per-file object URL once so renderSlidesStrip can reuse it
|
||||
// on every reorder without spawning a fresh FileReader per slide.
|
||||
// The old approach (new FileReader per file per render) ran dozens
|
||||
// of readers after a reorder and silently dropped some under memory
|
||||
// pressure, leaving the strip with broken thumbnails.
|
||||
const url = URL.createObjectURL(file);
|
||||
_slidesData[tid].push({ file, url });
|
||||
renderSlidesStrip(tid);
|
||||
}
|
||||
}
|
||||
@@ -1422,7 +1458,10 @@ function renderSlidesStrip(tid) {
|
||||
if (zone) zone.style.borderColor = '';
|
||||
return;
|
||||
}
|
||||
files.forEach((f, index) => {
|
||||
files.forEach((item, index) => {
|
||||
// Entries are {file, url}; cached url is stable across reorders.
|
||||
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
|
||||
if (item.file && !item.url) item.url = url;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
||||
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
|
||||
@@ -1441,10 +1480,15 @@ function renderSlidesStrip(tid) {
|
||||
const rb = document.createElement('button');
|
||||
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
|
||||
rb.innerHTML='<i class="bi bi-x"></i>';
|
||||
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); });
|
||||
rb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
const removed = _slidesData[tid].splice(index,1)[0];
|
||||
if (removed && removed.url) URL.revokeObjectURL(removed.url);
|
||||
renderSlidesStrip(tid);
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
|
||||
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f);
|
||||
img.src = url;
|
||||
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
|
||||
});
|
||||
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
|
||||
@@ -1455,6 +1499,7 @@ function renderSlidesStrip(tid) {
|
||||
|
||||
function clearSlidesForTrack(e, tid) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
|
||||
_slidesData[tid] = [];
|
||||
const input = document.getElementById('slides-input-' + tid);
|
||||
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
|
||||
@@ -1494,10 +1539,8 @@ function updateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e', '');
|
||||
const uid = 'ltme_' + n;
|
||||
const li = document.getElementById('csd_v_' + uid);
|
||||
const ti = document.getElementById('um-tf-title-' + trackId);
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
@@ -1512,49 +1555,111 @@ function updateTrackCard(trackId) {
|
||||
if (card) card.style.display = hasContent ? '' : 'none';
|
||||
}
|
||||
|
||||
const flagEl = document.getElementById('um-tc-flag-' + trackId);
|
||||
const titleEl = document.getElementById('um-tc-title-' + trackId);
|
||||
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
if (titleEl) {
|
||||
if (lang && title) titleEl.textContent = title + ' — ' + lang.label;
|
||||
else if (lang) titleEl.textContent = lang.label;
|
||||
else if (title) titleEl.textContent = title;
|
||||
else titleEl.textContent = '';
|
||||
}
|
||||
// Update WHICHEVER card id convention exists — the create modal uses
|
||||
// `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
|
||||
// both keeps this helper a single source of truth for any shared template.
|
||||
const flagEls = [
|
||||
document.getElementById('um-tc-flag-' + trackId),
|
||||
document.getElementById('edit-tc-flag-' + trackId),
|
||||
];
|
||||
const titleEls = [
|
||||
document.getElementById('um-tc-title-' + trackId),
|
||||
document.getElementById('edit-tc-title-' + trackId),
|
||||
];
|
||||
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
let display;
|
||||
if (lang && title) display = title + ' — ' + lang.label;
|
||||
else if (lang) display = lang.label;
|
||||
else if (title) display = title;
|
||||
else display = '';
|
||||
flagEls.forEach(el => { if (el) el.className = flagClass; });
|
||||
titleEls.forEach(el => { if (el) el.textContent = display; });
|
||||
}
|
||||
|
||||
// ── Extra track management ────────────────────────────────────
|
||||
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
function _ltBuildOptsHtml() {
|
||||
return LANG_OPTIONS_MODAL.map(o =>
|
||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
||||
<span class="csd-opt-main">${o.label}</span>
|
||||
<span class="csd-opt-sub">${o.native}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
// Audio picker handler shared by every track form (primary + extras) cloned
|
||||
// from the <template id="um-track-form-template"> blade partial.
|
||||
function handleTrackAudio(key, input) {
|
||||
const file = input.files && input.files[0];
|
||||
const fnameEl = document.getElementById('track-audio-fname-' + key);
|
||||
const box = document.getElementById('track-audio-box-' + key);
|
||||
if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
|
||||
if (box) box.style.borderColor = file ? '#22c55e' : '';
|
||||
|
||||
// Auto-fill the track title from the filename (same behavior as primary track)
|
||||
if (file) {
|
||||
const titleEl = document.getElementById('track-title-' + key);
|
||||
if (titleEl && !titleEl.value) {
|
||||
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
|
||||
updateTrackCard(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cloner for #um-track-form-template. Both the create modal and the
|
||||
// edit modal call this so there is ONE source for the track-form markup.
|
||||
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
|
||||
// substituted. The caller is responsible for appending it + initialising the
|
||||
// language picker + RTE on the new nodes.
|
||||
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
|
||||
const tpl = document.getElementById('um-track-form-template');
|
||||
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const subs = {
|
||||
'__N__': opts.key,
|
||||
'__LANGNAME__': opts.langName,
|
||||
'__TITLENAME__': opts.titleName,
|
||||
'__DESCNAME__': opts.descName,
|
||||
'__AUDIONAME__': opts.audioName,
|
||||
};
|
||||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
|
||||
const nodes = [];
|
||||
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
|
||||
nodes.forEach(node => {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
let v = attr.value;
|
||||
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
|
||||
if (v !== attr.value) node.setAttribute(attr.name, v);
|
||||
}
|
||||
});
|
||||
return frag;
|
||||
};
|
||||
|
||||
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
|
||||
// change listeners that keep whichever card is on-screen in sync with the form.
|
||||
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
|
||||
if (window.CSD) new CSD('lsd_track_' + key);
|
||||
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
|
||||
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
|
||||
const langInput = document.getElementById('track-lang-' + key);
|
||||
const titleInput = document.getElementById('track-title-' + key);
|
||||
const cb = onChange || (() => updateTrackCard(key));
|
||||
if (langInput) langInput.addEventListener('change', () => cb(key));
|
||||
if (titleInput) titleInput.addEventListener('input', () => cb(key));
|
||||
};
|
||||
|
||||
function addExtraTrackModal() {
|
||||
const n = ++_umExtraCount;
|
||||
const trackNum = n + 1;
|
||||
const uid = 'ltme_' + n;
|
||||
const key = 'e' + n;
|
||||
|
||||
// Track card (shown in the list on the main modal body)
|
||||
const card = document.createElement('div');
|
||||
card.className = 'um-track-card';
|
||||
card.id = 'um-tc-e' + n;
|
||||
card.id = 'um-tc-' + key;
|
||||
card.innerHTML = `
|
||||
<div class="um-tc-body">
|
||||
<div class="um-tc-left">
|
||||
<div class="um-tc-num">${trackNum}</div>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
|
||||
<div class="um-tc-info">
|
||||
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span>
|
||||
<span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-tc-right">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
|
||||
@@ -1564,100 +1669,24 @@ function addExtraTrackModal() {
|
||||
</div>`;
|
||||
document.getElementById('um-tc-extra').appendChild(card);
|
||||
|
||||
const formEl = document.createElement('div');
|
||||
formEl.className = 'um-track-form';
|
||||
formEl.id = 'um-tf-e' + n;
|
||||
formEl.style.display = 'none';
|
||||
formEl.innerHTML = `
|
||||
<div class="um-tf-row2" style="margin-bottom:14px;">
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label>
|
||||
<div class="csd-wrap" id="${uid}">
|
||||
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
|
||||
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
|
||||
<span class="csd-val ph">Select language</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
<div class="csd-panel" hidden role="listbox">
|
||||
<div class="csd-srch"><i class="bi bi-search"></i>
|
||||
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<ul class="csd-list">${_ltBuildOptsHtml()}</ul>
|
||||
<p class="csd-empty" hidden>No results</p>
|
||||
</div>
|
||||
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Title <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
|
||||
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Description <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<div class="rte-wrap" data-min-height="100px">
|
||||
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-music-note-beamed"></i> Audio File
|
||||
</label>
|
||||
<label class="lta-file-label"
|
||||
onmouseenter="this.style.borderColor='#e61e1e'"
|
||||
onmouseleave="this.style.borderColor='#222'">
|
||||
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
|
||||
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
|
||||
<input type="file" name="extra_track_files[]"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
|
||||
</label>
|
||||
<div id="slides-zone-e${n}" class="um-slides-zone"
|
||||
onclick="slidesZoneClick(event,'e${n}')"
|
||||
ondragover="slidesZoneDragover(event,'e${n}')"
|
||||
ondragleave="slidesZoneDragleave(event,'e${n}')"
|
||||
ondrop="slidesZoneDrop(event,'e${n}')">
|
||||
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
|
||||
onchange="handleSlidesForTrack('e${n}',this.files)">
|
||||
<div class="um-slides-ph" id="slides-ph-e${n}">
|
||||
<i class="bi bi-images"></i>
|
||||
<span>Click or drag to add cover images</span>
|
||||
</div>
|
||||
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
||||
<div id="slides-strip-e${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
|
||||
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
|
||||
<i class="bi bi-x-lg"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Clone the SHARED track-form template and substitute placeholders. The same
|
||||
// template is cloned by the edit modal (editAddExtraTrack / existing-track
|
||||
// loader), so a change here automatically shows up in both flows.
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: key,
|
||||
langName: 'extra_track_languages[]',
|
||||
titleName: 'extra_track_titles[]',
|
||||
descName: 'extra_track_descriptions[]',
|
||||
audioName: 'extra_track_files[]',
|
||||
});
|
||||
if (!frag) return;
|
||||
|
||||
document.getElementById('um-tf-extra').appendChild(formEl);
|
||||
_slidesData['e' + n] = [];
|
||||
const host = document.getElementById('um-tf-extra');
|
||||
host.appendChild(frag);
|
||||
_slidesData[key] = [];
|
||||
|
||||
if (window.CSD) new CSD(uid);
|
||||
|
||||
const hiddenInput = document.getElementById('csd_v_' + uid);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
|
||||
}
|
||||
|
||||
openTrackPopup('e' + n);
|
||||
window.initTrackFormNode(key);
|
||||
openTrackPopup(key);
|
||||
}
|
||||
|
||||
function removeExtraTrackModal(n) {
|
||||
@@ -1686,13 +1715,15 @@ function removeExtraTrackModal(n) {
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
||||
async function _chunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
|
||||
const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
|
||||
const CONCURRENCY = 3; // parallel in-flight chunks
|
||||
const total = Math.max(1, Math.ceil(file.size / CHUNK));
|
||||
const uploadId = (window.crypto && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
let uploadedBytes = 0;
|
||||
const uploadOne = async (i) => {
|
||||
const start = i * CHUNK;
|
||||
const end = Math.min(start + CHUNK, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
@@ -1700,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
|
||||
const fd = new FormData();
|
||||
fd.append('upload_id', uploadId);
|
||||
fd.append('chunk_index', i);
|
||||
fd.append('chunk_offset', start);
|
||||
fd.append('total_chunks', total);
|
||||
fd.append('filename', file.name);
|
||||
fd.append('chunk', blob, 'chunk');
|
||||
@@ -1715,8 +1747,20 @@ async function _chunkedUploadVideo(file, onProgress) {
|
||||
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (onProgress) onProgress({ loaded: end, total: file.size });
|
||||
uploadedBytes += (end - start);
|
||||
if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
|
||||
};
|
||||
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
await uploadOne(i);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
@@ -1753,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
}
|
||||
|
||||
const formData = new FormData(this);
|
||||
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
|
||||
if (_isMusicMode) {
|
||||
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
|
||||
(_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
|
||||
// Walk extra-track form sections in DOM order so the slide index lines up
|
||||
// with the positional `extra_track_files[]` the form already submits.
|
||||
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
|
||||
@@ -1763,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
if (!m) return;
|
||||
var tid = m[1];
|
||||
var files = _slidesData[tid] || [];
|
||||
files.forEach(function (f) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', f);
|
||||
files.forEach(function (s) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ $headerSocialMap = [
|
||||
<div class="ch-meta-row">
|
||||
<span class="ch-meta-item">
|
||||
<i class="bi bi-camera-video-fill"></i>
|
||||
{{ number_format($videos->count() + $shorts->count()) }} videos
|
||||
@php $vidTotal = $videos->count() + $shorts->count(); @endphp
|
||||
{{ number_format($vidTotal) }} {{ Str::plural('video', $vidTotal) }}
|
||||
</span>
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
@@ -136,7 +137,14 @@ $headerSocialMap = [
|
||||
<i class="bi bi-people-fill"></i>
|
||||
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
|
||||
</span>
|
||||
@if($user->location)
|
||||
{{-- Location, age and gender used to render in the public
|
||||
header meta row — visible to every visitor. That's a
|
||||
privacy leak the user flagged; those three now show
|
||||
only to the owner here, and only in the About tab for
|
||||
everyone (via the existing ch-about-card below). Join
|
||||
month stays public because it's channel metadata, not
|
||||
personal data. --}}
|
||||
@if($isOwner && $user->location)
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
<i class="bi bi-geo-alt-fill"></i>
|
||||
@@ -148,7 +156,7 @@ $headerSocialMap = [
|
||||
<i class="bi bi-calendar3"></i>
|
||||
Joined {{ $user->created_at->format('M Y') }}
|
||||
</span>
|
||||
@if($user->birthday)
|
||||
@if($isOwner && $user->birthday)
|
||||
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
@@ -156,13 +164,13 @@ $headerSocialMap = [
|
||||
{{ $age }} yrs
|
||||
</span>
|
||||
@endif
|
||||
@if($user->gender === 'male')
|
||||
@if($isOwner && $user->gender === 'male')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
|
||||
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
|
||||
Male
|
||||
</span>
|
||||
@elseif($user->gender === 'female')
|
||||
@elseif($isOwner && $user->gender === 'female')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
|
||||
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
|
||||
@@ -181,18 +189,25 @@ $headerSocialMap = [
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Horoscope + Compatibility strip & Social buttons side by side --}}
|
||||
@if($horoscope || $socialLinks->isNotEmpty())
|
||||
{{-- Horoscope + Compatibility strip & Social buttons side by side.
|
||||
The horoscope portion is sign-derived from the birthday the
|
||||
owner entered; it only renders to the owner (or to a visitor
|
||||
who's seeing the compatibility match-up). Pure social buttons
|
||||
still show to visitors. --}}
|
||||
@php
|
||||
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
|
||||
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
|
||||
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
|
||||
@endphp
|
||||
@if($showHoroStrip || $socialLinks->isNotEmpty())
|
||||
@php
|
||||
if ($showCompat) {
|
||||
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
|
||||
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
|
||||
}
|
||||
@endphp
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
|
||||
@if($horoscope)
|
||||
@if($showHoroStrip)
|
||||
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
|
||||
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
|
||||
<div class="ch-horo-sign-part">
|
||||
@@ -361,10 +376,15 @@ $headerSocialMap = [
|
||||
<span class="ch-tab-badge">{{ $videos->count() }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@if($shorts->count() > 0)
|
||||
{{-- Always show the Shorts tab for the owner so the "My Shorts"
|
||||
sidebar link always lands somewhere; for visitors only when
|
||||
there's content to show. --}}
|
||||
@if($isOwner || $shorts->count() > 0)
|
||||
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
|
||||
Shorts
|
||||
@if($shorts->count() > 0)
|
||||
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@endif
|
||||
@if(($playlists && $playlists->count() > 0) || $isOwner)
|
||||
@@ -394,9 +414,9 @@ $headerSocialMap = [
|
||||
{{-- ── LEFT: composer + feed ── --}}
|
||||
<div>
|
||||
|
||||
{{-- Composer (owner only) --}}
|
||||
{{-- Composer (owner only, hidden when previewing as a visitor) --}}
|
||||
@auth
|
||||
@if(Auth::id() === $user->id)
|
||||
@if(Auth::id() === $user->id && !$preview)
|
||||
<div class="ch-composer">
|
||||
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
|
||||
@csrf
|
||||
@@ -528,7 +548,7 @@ $headerSocialMap = [
|
||||
<div class="ch-post-video-info">
|
||||
<div class="ch-post-video-title">{{ $v->title }}</div>
|
||||
<div class="ch-post-video-meta">
|
||||
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) }}</span>
|
||||
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ $v->view_count > 0 ? \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) : '0' }}</span>
|
||||
@if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif
|
||||
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
|
||||
</div>
|
||||
@@ -663,18 +683,18 @@ $headerSocialMap = [
|
||||
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||
<span class="ch-sort-label">Sort</span>
|
||||
<div class="ch-sort-pills">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) . '#videos' }}"
|
||||
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yt-video-grid" id="chVidGrid">
|
||||
@foreach($videos as $video)
|
||||
@include('components.video-card', ['video' => $video])
|
||||
@include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
|
||||
@endforeach
|
||||
<div class="ch-vid-no-results" id="chVidNoResults">
|
||||
<i class="bi bi-search"></i>
|
||||
@@ -699,8 +719,15 @@ $headerSocialMap = [
|
||||
</div>
|
||||
|
||||
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
|
||||
@if($shorts->count() > 0)
|
||||
@if($isOwner || $shorts->count() > 0)
|
||||
<div class="ch-tab-content" id="tab-shorts">
|
||||
@if($shorts->isEmpty())
|
||||
<div class="ch-empty">
|
||||
<div class="ch-empty-icon"><i class="bi bi-phone"></i></div>
|
||||
<h3>No shorts yet</h3>
|
||||
<p>Vertical videos under 60 seconds appear here.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="ch-shorts-grid">
|
||||
@foreach($shorts as $short)
|
||||
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
||||
@@ -722,6 +749,7 @@ $headerSocialMap = [
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -753,8 +781,8 @@ $headerSocialMap = [
|
||||
@endif
|
||||
<div class="ch-playlist-overlay"></div>
|
||||
<div class="ch-playlist-count">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
{{ $playlist->video_count }} videos
|
||||
<i class="bi bi-play-fill" aria-hidden="true"></i>
|
||||
{{ $playlist->video_count }} {{ Str::plural('video', $playlist->video_count) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-playlist-name">{{ $playlist->name }}</div>
|
||||
@@ -768,7 +796,13 @@ $headerSocialMap = [
|
||||
@else
|
||||
<i class="bi bi-globe"></i> Public
|
||||
@endif
|
||||
· <i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
|
||||
{{-- Hide the view-count row entirely when the playlist has
|
||||
never been viewed. Previously showed "· eye 0.0" because
|
||||
Number::abbreviate(0, precision: 1) prints "0.0". --}}
|
||||
@if($playlist->view_count > 0)
|
||||
· <i class="bi bi-eye" aria-hidden="true"></i>
|
||||
{{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -1057,7 +1091,7 @@ $headerSocialMap = [
|
||||
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
|
||||
<div class="ch-stat-cards">
|
||||
<div class="ch-stat-card">
|
||||
<div class="ch-stat-card-val">{{ \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) }}</div>
|
||||
<div class="ch-stat-card-val">{{ $totalViews > 0 ? \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) : '0' }}</div>
|
||||
<div class="ch-stat-card-key">Total Views</div>
|
||||
</div>
|
||||
<div class="ch-stat-card">
|
||||
@@ -1171,7 +1205,13 @@ if ($oldLinks) {
|
||||
<div class="ch-settings-field">
|
||||
<label class="ch-settings-label">Gender</label>
|
||||
<select name="gender" class="ch-settings-input" style="cursor:pointer;">
|
||||
<option value="">Prefer not to say</option>
|
||||
{{-- Empty value stands for "not chosen yet"
|
||||
— different from the explicit "prefer not
|
||||
to say" option. Previously both rendered
|
||||
the same label, so users saw two
|
||||
identical choices and the stats grouping
|
||||
ambiguously bucketed one of them. --}}
|
||||
<option value="">Select…</option>
|
||||
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
|
||||
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
|
||||
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
|
||||
@@ -1338,9 +1378,14 @@ if ($oldLinks) {
|
||||
@php
|
||||
$notifPrefs = $user->notification_preferences ?? [];
|
||||
$notifDefs = \App\Models\User::notifDefaults();
|
||||
// function_exists guard so a second render in the same PHP
|
||||
// process (feature tests fire two requests in one process)
|
||||
// doesn't fatal with "Cannot redeclare notifVal()".
|
||||
if (!function_exists('notifVal')) {
|
||||
function notifVal(array $prefs, array $defs, string $key): bool {
|
||||
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="ch-settings-card" id="notifPrefsCard">
|
||||
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Watch History | TAKEONE')
|
||||
@section('title', 'Watch History | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Liked Videos | TAKEONE')
|
||||
@section('title', 'Liked Videos | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -342,10 +342,17 @@
|
||||
/* ══════════════════════════════════════════════════
|
||||
VIDEO GRID
|
||||
══════════════════════════════════════════════════ */
|
||||
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } }
|
||||
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||
/* Fluid grid: as many 260-px columns as fit, each one stretching to fill
|
||||
the leftover space. Removes the three fixed breakpoints that produced
|
||||
different card widths at 1101px vs 1500px, and `min-width:0` on every
|
||||
grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen
|
||||
its track past the configured 1fr. */
|
||||
.yt-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.yt-video-grid > * { min-width: 0; }
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
SHORTS GRID
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Profile | TAKEONE')
|
||||
@section('title', 'Profile | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Settings | TAKEONE')
|
||||
@section('title', 'Settings | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
|
||||
@@ -42,6 +42,34 @@
|
||||
}
|
||||
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
|
||||
|
||||
/* Channel results — card list sized to sit comfortably above the video
|
||||
grid. Avatar is a fixed 56px circle; name + handle sit to the right. */
|
||||
.search-channels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.search-channel-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.search-channel-card:hover { background: rgba(255,255,255,.08); }
|
||||
.search-channel-card img,
|
||||
.search-channel-ph {
|
||||
width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||
background: #333; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; color: var(--text-secondary);
|
||||
}
|
||||
.search-channel-text { min-width: 0; }
|
||||
.search-channel-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-channel-handle { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
|
||||
/* Language chips */
|
||||
@@ -59,21 +87,34 @@
|
||||
<div class="yt-filter-bar-wrap">
|
||||
<button type="button" class="yt-filter-nav left" aria-label="Scroll left" onclick="scrollFilterBar(-1)"><i class="bi bi-chevron-left"></i></button>
|
||||
<button type="button" class="yt-filter-nav right" aria-label="Scroll right" onclick="scrollFilterBar(1)"><i class="bi bi-chevron-right"></i></button>
|
||||
<div class="yt-filter-bar">
|
||||
<div class="yt-filter-bar" role="navigation" aria-label="Browse filters">
|
||||
{{-- aria-current="page" on the active chip so assistive tech can read
|
||||
the current filter. "New to You" was renamed to "Recent" — it was
|
||||
just the latest filter, not a personalised recommendation, so the
|
||||
old label overpromised. --}}
|
||||
@php
|
||||
$isAll = $activeFilter === 'all';
|
||||
$isTrending = request()->is('trending');
|
||||
$isMusic = $activeFilter === 'music';
|
||||
$isMatch = $activeFilter === 'match';
|
||||
$isShorts = request()->is('shorts');
|
||||
$isPlaylists = $activeFilter === 'playlists';
|
||||
$isRecent = $activeFilter === 'latest';
|
||||
@endphp
|
||||
<a href="{{ route('videos.index') }}"
|
||||
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
|
||||
class="yt-chip {{ $isAll ? 'active' : '' }}" @if($isAll) aria-current="page"@endif>All</a>
|
||||
<a href="{{ route('videos.trending') }}"
|
||||
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a>
|
||||
class="yt-chip {{ $isTrending ? 'active' : '' }}" @if($isTrending) aria-current="page"@endif>Trending</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'music']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a>
|
||||
class="yt-chip {{ $isMusic ? 'active' : '' }}" @if($isMusic) aria-current="page"@endif>Music</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'match']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a>
|
||||
class="yt-chip {{ $isMatch ? 'active' : '' }}" @if($isMatch) aria-current="page"@endif>Sports</a>
|
||||
<a href="{{ route('videos.shorts') }}"
|
||||
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a>
|
||||
class="yt-chip {{ $isShorts ? 'active' : '' }}" @if($isShorts) aria-current="page"@endif>Shorts</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
|
||||
class="yt-chip {{ $isPlaylists ? 'active' : '' }}" @if($isPlaylists) aria-current="page"@endif>Playlists</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
|
||||
class="yt-chip {{ $isRecent ? 'active' : '' }}" @if($isRecent) aria-current="page"@endif>Recent</a>
|
||||
|
||||
@if(!empty($langChips ?? []))
|
||||
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
||||
@@ -81,6 +122,7 @@
|
||||
@foreach($langChips as $chip)
|
||||
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
||||
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
||||
@if($activeLang === $chip['code']) aria-current="page"@endif
|
||||
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
||||
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
||||
<span>{{ $chip['name'] }}</span>
|
||||
@@ -100,7 +142,8 @@
|
||||
@php
|
||||
$searchShorts = $shorts ?? collect();
|
||||
$searchPlaylists = $playlists ?? collect();
|
||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
|
||||
$searchChannels = $channels ?? collect();
|
||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
|
||||
@endphp
|
||||
|
||||
<div class="search-info">
|
||||
@@ -108,11 +151,33 @@
|
||||
<p>
|
||||
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchShorts->count()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchChannels->count()) · {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchPlaylists->count()) · {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($totalResults === 0) No results found @endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($searchChannels->count())
|
||||
<div class="results-section-title"><i class="bi bi-person-circle"></i> Channels</div>
|
||||
<div class="search-channels-grid">
|
||||
@foreach($searchChannels as $channel)
|
||||
<a href="{{ route('channel', $channel->channel) }}" class="search-channel-card">
|
||||
@if($channel->avatar_url)
|
||||
<img src="{{ $channel->avatar_url }}" alt="{{ $channel->name }}" width="56" height="56" loading="lazy">
|
||||
@else
|
||||
<div class="search-channel-ph"><i class="bi bi-person"></i></div>
|
||||
@endif
|
||||
<div class="search-channel-text">
|
||||
<div class="search-channel-name">{{ $channel->name }}</div>
|
||||
@if($channel->username)
|
||||
<div class="search-channel-handle">@{{ $channel->username }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($videos->count())
|
||||
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
||||
<div class="yt-video-grid">
|
||||
@@ -172,6 +237,60 @@
|
||||
══════════════════════════════════════════════ --}}
|
||||
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
|
||||
|
||||
@if(!empty($homeShorts) && $homeShorts->count())
|
||||
{{-- Horizontally-scrolling Shorts shelf. Sits above the main feed grid
|
||||
so the user sees fresh short-form content without the feed being
|
||||
drowned in 60-second portrait clips. --}}
|
||||
<section class="home-shorts-shelf" aria-labelledby="home-shorts-heading">
|
||||
<div class="home-shorts-shelf-head">
|
||||
<h2 id="home-shorts-heading"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i> Shorts</h2>
|
||||
<a href="{{ route('videos.shorts') }}" class="home-shorts-see-all">See all</a>
|
||||
</div>
|
||||
<div class="home-shorts-shelf-track">
|
||||
@foreach($homeShorts as $s)
|
||||
<a class="home-short-card" href="{{ route('videos.shorts.focused', $s) }}"
|
||||
aria-label="Open short: {{ $s->title }}">
|
||||
<img src="{{ $s->thumbnail ? route('media.thumbnail', $s->thumbnail) : 'https://picsum.photos/seed/'.$s->id.'/180/320' }}"
|
||||
alt="" width="150" height="266" loading="lazy" decoding="async">
|
||||
<div class="home-short-card-overlay">
|
||||
<div class="home-short-card-title">{{ Str::limit($s->title, 40) }}</div>
|
||||
<div class="home-short-card-views">{{ number_format($s->view_count) }} views</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.home-shorts-shelf { margin: 8px 0 24px; }
|
||||
.home-shorts-shelf-head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:12px; }
|
||||
.home-shorts-shelf-head h2 { font-size:18px; font-weight:600; margin:0; display:flex; align-items:center; gap:8px; }
|
||||
.home-shorts-shelf-head h2 i { color:#ef4444; }
|
||||
.home-shorts-see-all { color: var(--text-secondary); font-size:13px; text-decoration:none; }
|
||||
.home-shorts-see-all:hover { color: var(--text-primary); text-decoration: underline; }
|
||||
.home-shorts-shelf-track {
|
||||
display:flex; gap:12px; overflow-x:auto; scroll-snap-type: x proximity;
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.home-shorts-shelf-track::-webkit-scrollbar { display:none; }
|
||||
.home-short-card {
|
||||
flex: 0 0 150px; aspect-ratio: 9/16; border-radius: 10px;
|
||||
overflow: hidden; position: relative; scroll-snap-align: start;
|
||||
text-decoration: none; color: inherit; background: #1a1a1a;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.home-short-card:hover { transform: translateY(-2px); }
|
||||
.home-short-card img { width:100%; height:100%; object-fit: cover; display:block; }
|
||||
.home-short-card-overlay {
|
||||
position:absolute; left:0; right:0; bottom:0; padding: 20px 8px 8px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.75));
|
||||
color: #fff;
|
||||
}
|
||||
.home-short-card-title { font-size:12px; font-weight:600; line-height:1.3; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
|
||||
.home-short-card-views { font-size:11px; opacity:.8; margin-top:2px; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
@if(isset($feedItems) && $feedItems->count())
|
||||
<div class="yt-video-grid">
|
||||
@foreach($feedItems as $entry)
|
||||
@@ -211,6 +330,17 @@
|
||||
@endif
|
||||
|
||||
@endif
|
||||
|
||||
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
|
||||
fetches the next cursor page when this comes into view and appends the
|
||||
rendered cards to the grid above. A visible "Load more" button is the
|
||||
always-available fallback. --}}
|
||||
<div id="feed-sentinel"
|
||||
data-feed-filter="{{ $filter ?? 'all' }}"
|
||||
style="height:1px;"></div>
|
||||
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
|
||||
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
|
||||
</div>
|
||||
</div>{{-- /filter-content --}}
|
||||
|
||||
@endsection
|
||||
@@ -354,5 +484,69 @@
|
||||
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
||||
});
|
||||
})();
|
||||
|
||||
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
|
||||
The server renders the first page of 60 cards. Everything after that is
|
||||
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
|
||||
into view. The sentinel sits right after the grid, so we never fetch
|
||||
until the user has actually scrolled past the first page.
|
||||
|
||||
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
|
||||
without having to rebind listeners. Fallback: an always-visible "Load
|
||||
more" button that users without IntersectionObserver can click. */
|
||||
(function () {
|
||||
const sentinel = document.getElementById('feed-sentinel');
|
||||
const grid = document.querySelector('.yt-video-grid');
|
||||
const btnWrap = document.getElementById('feed-loadmore-wrap');
|
||||
const btn = document.getElementById('feed-loadmore');
|
||||
if (!sentinel || !grid) return;
|
||||
|
||||
let cursor = ''; // null token = next request is the first continuation page
|
||||
let busy = false;
|
||||
let done = false;
|
||||
const filter = sentinel.dataset.feedFilter || 'all';
|
||||
|
||||
async function loadMore() {
|
||||
if (busy || done) return;
|
||||
busy = true;
|
||||
try {
|
||||
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
|
||||
url.searchParams.set('filter', filter);
|
||||
if (cursor) url.searchParams.set('cursor', cursor);
|
||||
const resp = await fetch(url.toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok) throw new Error('bad status: ' + resp.status);
|
||||
const data = await resp.json();
|
||||
if (data.items) {
|
||||
grid.insertAdjacentHTML('beforeend', data.items);
|
||||
}
|
||||
cursor = data.next_cursor || '';
|
||||
done = !data.has_more;
|
||||
} catch (e) {
|
||||
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
|
||||
btnWrap.style.display = 'block';
|
||||
} finally {
|
||||
busy = false;
|
||||
if (done) {
|
||||
btnWrap.style.display = 'none';
|
||||
sentinel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
btn?.addEventListener('click', loadMore);
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||
}, { rootMargin: '400px 0px' });
|
||||
io.observe(sentinel);
|
||||
} else {
|
||||
// No IntersectionObserver → surface the Load More fallback immediately.
|
||||
btnWrap.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -7,7 +7,12 @@
|
||||
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
||||
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
||||
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
||||
$showBox = $hasDesc || $isVideoOwner;
|
||||
$llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
// Optional pre-rendered block shown above the description inside the About
|
||||
// panel. Only the `match` type passes it (the bout summary card); when it is
|
||||
// unset every other type renders exactly as before.
|
||||
$aboutPrepend = $aboutPrepend ?? null;
|
||||
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
|
||||
@endphp
|
||||
@if ($showBox)
|
||||
<style>
|
||||
@@ -36,6 +41,27 @@
|
||||
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
|
||||
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
|
||||
.vdb-show-more.expanded i { transform:rotate(180deg); }
|
||||
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
|
||||
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
|
||||
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
|
||||
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
|
||||
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
|
||||
.vdb-desc-split { display:block; }
|
||||
/* Line-paired view: each row = one original line + its translation, aligned on
|
||||
the same baseline so skimming across the divider stays easy. */
|
||||
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
|
||||
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
||||
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.vdb-tr-row { display:contents; }
|
||||
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
|
||||
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
|
||||
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
|
||||
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
|
||||
@media (max-width:768px) {
|
||||
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
|
||||
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
|
||||
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="vdb-wrap" id="vdbWrap">
|
||||
@@ -51,6 +77,9 @@
|
||||
</div>
|
||||
|
||||
<div class="vdb-panel active" id="vdb-about">
|
||||
@if($aboutPrepend !== null)
|
||||
{!! $aboutPrepend !!}
|
||||
@endif
|
||||
<div class="vdb-meta">
|
||||
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
@@ -60,11 +89,21 @@
|
||||
@if(isset($descriptionSlot))
|
||||
{!! $descriptionSlot !!}
|
||||
@elseif($renderedDescription !== '')
|
||||
<div id="vdbDescSplit" class="vdb-desc-split">
|
||||
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
||||
</div>
|
||||
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
|
||||
@else
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
||||
@endif
|
||||
@if($llmEnabledVdb)
|
||||
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
|
||||
data-video-key="{{ $video->getRouteKey() }}"
|
||||
data-csrf="{{ csrf_token() }}">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||
<span class="vdb-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($isVideoOwner)
|
||||
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
|
||||
};
|
||||
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
||||
window.addEventListener('load', window._vdbScheduleOverflowCheck);
|
||||
|
||||
// ── Description translate ─────────────────────────────
|
||||
// Lives on the About tab. Falls back to navigator.language for the target.
|
||||
function _vdbTargetLang() {
|
||||
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
|
||||
}
|
||||
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
|
||||
const split = document.getElementById('vdbDescSplit');
|
||||
const showMore = document.getElementById('vdbShowMore');
|
||||
const label = btn.querySelector('.vdb-translate-label');
|
||||
if (!split) return;
|
||||
let grid = document.getElementById('vdbTranslationGrid');
|
||||
|
||||
const showTranslation = () => {
|
||||
split.classList.add('has-translation');
|
||||
if (grid) grid.style.display = 'grid';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
if (showMore) showMore.style.display = 'none';
|
||||
};
|
||||
const hideTranslation = () => {
|
||||
split.classList.remove('has-translation');
|
||||
if (grid) grid.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
if (label) label.textContent = 'Translate';
|
||||
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
||||
};
|
||||
|
||||
if (grid && grid.dataset.loaded === '1') {
|
||||
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
if (label) label.textContent = 'Translating…';
|
||||
try {
|
||||
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ target: _vdbTargetLang() })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
||||
const segs = Array.isArray(d.segments) ? d.segments : [];
|
||||
if (!segs.length) throw new Error('Translation failed');
|
||||
|
||||
// Build the line-paired grid. Each original line and its translation are
|
||||
// siblings in the same CSS grid, so a short line on one side still shares
|
||||
// its row with its counterpart on the other side.
|
||||
if (!grid) {
|
||||
grid = document.createElement('div');
|
||||
grid.id = 'vdbTranslationGrid';
|
||||
grid.className = 'vdb-translation-grid';
|
||||
split.appendChild(grid);
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'vdb-translation-meta';
|
||||
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
|
||||
grid.appendChild(meta);
|
||||
|
||||
segs.forEach(seg => {
|
||||
const origText = (seg.original ?? '').toString();
|
||||
const trText = (seg.translated ?? '').toString();
|
||||
const o = document.createElement('div');
|
||||
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
|
||||
o.textContent = origText;
|
||||
const t = document.createElement('div');
|
||||
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
|
||||
t.textContent = trText;
|
||||
grid.appendChild(o);
|
||||
grid.appendChild(t);
|
||||
});
|
||||
grid.dataset.loaded = '1';
|
||||
showTranslation();
|
||||
} catch (err) {
|
||||
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
|
||||
if (label) label.textContent = 'Translate';
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
};
|
||||
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
|
||||
document.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('#vdbTranslateBtn');
|
||||
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@@ -37,10 +37,10 @@
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
|
||||
<span data-msb="redName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['name'] ?: 'RED' }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
@@ -76,14 +76,14 @@
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="blueName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
|
||||
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<span data-msb="blueClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['club'] ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,8 +78,10 @@
|
||||
el.textContent = val || '';
|
||||
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
||||
};
|
||||
// Replace the placeholder "CLUB LOGO" text with a real <img>
|
||||
// (or leave the placeholder if no image). Never touches inline styles.
|
||||
// When a club_logo path is set, drop a real <img> into the empty
|
||||
// placeholder box. When it isn't, the box stays empty — the
|
||||
// inline-styled background keeps the slot visible without
|
||||
// showing the stale "CLUB LOGO" text that used to render there.
|
||||
const setImage = (name, path) => {
|
||||
const el = $(name); if (!el) return;
|
||||
if (!path) return;
|
||||
@@ -90,8 +92,9 @@
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
||||
el.appendChild(img);
|
||||
};
|
||||
// Swap the placeholder "FLAG" box for a real flag image by pointing
|
||||
// its background-image at the self-hosted flag-icons SVG.
|
||||
// When an ISO2 is set, point the flag box's background-image at
|
||||
// the self-hosted flag-icons SVG. When it isn't, the empty box
|
||||
// stays empty — no "FLAG" placeholder text.
|
||||
const setFlag = (name, iso2) => {
|
||||
const el = $(name); if (!el) return;
|
||||
const code = (iso2 || '').toLowerCase();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Shorts - ' . config('app.name'))
|
||||
@section('title', 'Shorts | ' . config('app.name'))
|
||||
|
||||
@section('main_class', 'shorts-view-page')
|
||||
|
||||
@@ -432,9 +432,22 @@
|
||||
<script>
|
||||
(function () {
|
||||
const main = document.getElementById('main');
|
||||
let muted = false;
|
||||
// Persist the mute state across sessions. Default to muted so autoplay
|
||||
// policies don't silently fail on first load; the user can unmute once
|
||||
// and we remember it from then on.
|
||||
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
||||
let current = -1;
|
||||
|
||||
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
||||
// before the observer starts, so autoplay fires on the target short
|
||||
// rather than the first in document order. ────────────────────
|
||||
@if($startId)
|
||||
(function () {
|
||||
const target = document.querySelector('.short-item[data-id="{{ (int) $startId }}"]');
|
||||
if (target) target.scrollIntoView({ behavior: 'instant' in window ? 'instant' : 'auto', block: 'start' });
|
||||
})();
|
||||
@endif
|
||||
|
||||
// ── IntersectionObserver — auto-play visible short ─────────
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
@@ -446,12 +459,23 @@
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||
if (current !== -1 && current !== idx) pauseShort(current);
|
||||
current = idx;
|
||||
|
||||
// ── Reflect the active short in the URL (no history entry) ──
|
||||
// Users can copy-paste the address bar and get back here; the
|
||||
// back button still exits the feed instead of walking through
|
||||
// every short they viewed.
|
||||
try {
|
||||
const vid = item.dataset.id;
|
||||
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
||||
} catch (_) {}
|
||||
|
||||
video.muted = muted;
|
||||
video.volume = 1;
|
||||
video.play().catch(() => {
|
||||
// autoplay blocked — start muted then unmute
|
||||
// autoplay blocked — fall back to muted
|
||||
video.muted = true;
|
||||
muted = true;
|
||||
localStorage.setItem('shortsMuted', '1');
|
||||
updateMuteIcon(idx);
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
@@ -500,6 +524,7 @@
|
||||
// ── Mute toggle ────────────────────────────────────────────
|
||||
window.toggleMute = function(idx) {
|
||||
muted = !muted;
|
||||
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
if (video) video.muted = muted;
|
||||
updateMuteIcon(idx);
|
||||
@@ -528,6 +553,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mouse wheel nav (desktop) ──────────────────────────────
|
||||
// One wheel event = one short, debounced so a trackpad flick doesn't
|
||||
// skip five shorts at once. The main scroll container's own snap would
|
||||
// nearly handle this, but a slow scroll leaves the feed mid-snap on
|
||||
// some browsers — this makes the step explicit and symmetrical.
|
||||
let wheelLock = false;
|
||||
main?.addEventListener('wheel', (e) => {
|
||||
if (wheelLock) { e.preventDefault(); return; }
|
||||
if (Math.abs(e.deltaY) < 10) return; // ignore tiny jogs
|
||||
wheelLock = true;
|
||||
setTimeout(() => { wheelLock = false; }, 450);
|
||||
e.preventDefault();
|
||||
const dir = e.deltaY > 0 ? 1 : -1;
|
||||
if (current !== -1) navigateShort(dir, current);
|
||||
}, { passive: false });
|
||||
|
||||
// ── Like ───────────────────────────────────────────────────
|
||||
window.shortLike = function(videoId) {
|
||||
@auth
|
||||
|
||||
@@ -836,15 +836,22 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||
{{-- Real <a href> cards so right-click / open-in-new-tab /
|
||||
middle-click and screen readers all work. The SPA
|
||||
transition hooks in types/*.blade.php still intercept
|
||||
these clicks via data-rec-url for a soft load. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||
alt="{{ $recVideo->title }}">
|
||||
alt="{{ $recVideo->title }}" width="168" height="94"
|
||||
loading="lazy" decoding="async">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||
alt="{{ $recVideo->title }}">
|
||||
alt="{{ $recVideo->title }}" width="168" height="94"
|
||||
loading="lazy" decoding="async">
|
||||
@endif
|
||||
@if ($recVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||
@@ -852,14 +859,15 @@
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
<i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"
|
||||
aria-hidden="true"></i>
|
||||
{{ Str::limit($recVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
@@ -868,7 +876,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -177,9 +177,13 @@
|
||||
<div class="trending-header">
|
||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||
<div class="trending-filters">
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||
{{-- Hours line up with the labels: 24 = Today, 168 = This Week
|
||||
(7×24), 720 = This Month (30×24). The old 24/48/168 trio
|
||||
claimed Month but silently showed Week data because the
|
||||
controller clamped the max to 168. --}}
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}" @if($hours == 24) aria-current="page"@endif>Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}" @if($hours == 168) aria-current="page"@endif>This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 720]) }}" class="{{ $hours == 720 ? 'active' : '' }}" @if($hours == 720) aria-current="page"@endif>This Month</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -307,7 +307,10 @@
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
transition: background .15s;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
|
||||
|
||||
.sidebar-video-card.current-video {
|
||||
background: rgba(239,68,68,.12);
|
||||
@@ -977,9 +980,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
{{-- Real <a href> so right-click / middle-click /
|
||||
open-in-new-tab works. recGoTo() still runs
|
||||
on a plain left-click for the SPA swap. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
|
||||
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -1013,7 +1020,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -3144,9 +3144,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
{{-- Real <a href> so right-click / middle-click /
|
||||
open-in-new-tab works. recGoTo() still runs
|
||||
on a plain left-click for the SPA swap. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
|
||||
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -3181,7 +3185,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -307,7 +307,12 @@
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
transition: background .15s;
|
||||
/* Card is now an <a href> for real right-click / open-in-new-tab
|
||||
/ middle-click support. Strip the default link styling. */
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
|
||||
|
||||
.sidebar-video-card.current-video {
|
||||
background: rgba(239,68,68,.12);
|
||||
@@ -967,9 +972,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
{{-- Real <a href> so right-click / middle-click /
|
||||
open-in-new-tab works. recGoTo() still runs
|
||||
on a plain left-click for the SPA swap. --}}
|
||||
<a class="sidebar-video-card"
|
||||
href="{{ route('videos.show', $recVideo) }}"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
|
||||
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -1003,7 +1012,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Welcome - ' . config('app.name'))
|
||||
@section('title', 'Welcome | ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="welcome-container">
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ Route::middleware('guest')->group(function () {
|
||||
|
||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
|
||||
Route::post('login', [AuthenticatedSessionController::class, 'store'])
|
||||
->middleware('throttle:10,1') // max 10 login attempts per minute per IP
|
||||
->middleware('throttle:5,1') // max 5 login attempts per minute per IP
|
||||
->name('login.store');
|
||||
});
|
||||
|
||||
|
||||
+23
-1
@@ -24,9 +24,20 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
|
||||
|
||||
// Video routes - public
|
||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
||||
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
||||
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
|
||||
|
||||
// Site-wide search. /videos/search is the historical URL; keep it as a
|
||||
// signed-forward redirect so bookmarks and external links don't break.
|
||||
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
|
||||
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
|
||||
return redirect()->route('search', $request->only('q'), 301);
|
||||
})->name('videos.search');
|
||||
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
||||
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
|
||||
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
|
||||
// on the branded errors/404 page.
|
||||
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
|
||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
|
||||
Route::get('/videos/{numericId}', function ($numericId) {
|
||||
@@ -58,6 +69,7 @@ Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsPr
|
||||
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
|
||||
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
|
||||
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
|
||||
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
|
||||
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
|
||||
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
|
||||
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
|
||||
@@ -137,6 +149,7 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
|
||||
|
||||
// Comment routes
|
||||
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
|
||||
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||
@@ -159,6 +172,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/history', [UserController::class, 'history'])->name('history');
|
||||
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
|
||||
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
||||
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
|
||||
|
||||
// Logout all other devices
|
||||
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
|
||||
@@ -171,6 +185,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
|
||||
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
||||
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
||||
// YouTube-style @handle alias. For now `handle` is just the existing
|
||||
// `username` slug — if the two ever diverge, flip the controller lookup
|
||||
// in UserController::channel() to prefer a dedicated `handle` column.
|
||||
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
|
||||
// — Laravel's router accepts both because `@` is allowed in URI path segments.
|
||||
Route::get('/@{handle}', [UserController::class, 'channel'])
|
||||
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
||||
->name('channel.handle');
|
||||
|
||||
// Post routes - auth required
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Rules\ReservedIdentifier;
|
||||
use App\Support\SafeNotifier;
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 1 audit remediation: security + data-integrity regression tests.
|
||||
*
|
||||
* These tests run on a scratch SQLite file in storage/testing.sqlite so they
|
||||
* can't collide with the shared dev DB. phpunit.xml keeps DB_CONNECTION out
|
||||
* of the test env so this per-class override is where the swap happens.
|
||||
*/
|
||||
class Phase1SecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Force the test runner onto an in-memory SQLite BEFORE the framework
|
||||
// boots, so RefreshDatabase migrates the throwaway DB and never the
|
||||
// shared dev file. Env vars are read during app bootstrap in
|
||||
// CreatesApplication, which is why putenv has to come first.
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
// Strip the two middleware that fight the test client: CSRF (no
|
||||
// token flow in postJson) and AuthenticateSession (which rotates
|
||||
// the session on cross-request assertions and 302s to APP_URL).
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── ReservedIdentifier rule ──────────────────────────
|
||||
|
||||
public function test_reserved_name_rule_blocks_impersonation_candidates(): void
|
||||
{
|
||||
$rule = new ReservedIdentifier('name');
|
||||
|
||||
foreach (['admin', 'Administrator', 'super_admin', 'super-admin',
|
||||
'SuperAdmin', 'TAKEONE', 'support', 'root', 'Postmaster'] as $name) {
|
||||
$failed = false;
|
||||
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
|
||||
$this->assertTrue($failed, "Expected name '{$name}' to be reserved");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_reserved_email_rule_matches_on_local_part_only(): void
|
||||
{
|
||||
$rule = new ReservedIdentifier('email');
|
||||
|
||||
$failed = false;
|
||||
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
|
||||
$this->assertTrue($failed);
|
||||
|
||||
$failed = false;
|
||||
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
|
||||
$this->assertFalse($failed, 'Only exact local-part should match; "admin.user" is a real person.');
|
||||
}
|
||||
|
||||
public function test_reserved_rule_allows_normal_names(): void
|
||||
{
|
||||
$rule = new ReservedIdentifier('name');
|
||||
|
||||
foreach (['Alice', 'Bob Smith', 'John Doe', 'الحسن'] as $name) {
|
||||
$failed = false;
|
||||
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
|
||||
$this->assertFalse($failed, "Expected name '{$name}' to be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SafeNotifier swallows mail failures ──────────────
|
||||
|
||||
public function test_safe_notifier_logs_but_does_not_throw_on_failure(): void
|
||||
{
|
||||
// A fake notifiable whose notify() blows up — stands in for the Gmail
|
||||
// 535 case where sending throws out of the transport layer.
|
||||
$exploding = new class {
|
||||
public function notify($n): void { throw new \RuntimeException('smtp exploded'); }
|
||||
public function getKey(): int { return 1; }
|
||||
};
|
||||
|
||||
$notification = new \App\Notifications\VerifyEmail;
|
||||
|
||||
// Call must not throw — if it did, PHPUnit would mark this test as errored.
|
||||
SafeNotifier::send($exploding, $notification);
|
||||
|
||||
$this->assertTrue(true, 'SafeNotifier swallowed the throw');
|
||||
}
|
||||
|
||||
// ─── Comment dedupe ───────────────────────────────────
|
||||
|
||||
public function test_identical_comments_within_60s_are_collapsed(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($user);
|
||||
|
||||
$first = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
|
||||
$first->assertOk();
|
||||
$firstId = $first->json('comment.id');
|
||||
|
||||
$second = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
|
||||
$second->assertOk();
|
||||
$secondId = $second->json('comment.id');
|
||||
|
||||
$this->assertSame($firstId, $secondId, 'Duplicate within 60s should return the first row, not insert a new one.');
|
||||
$this->assertSame(1, Comment::where('video_id', $video->id)->count());
|
||||
}
|
||||
|
||||
public function test_idempotency_key_returns_the_same_comment(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($user);
|
||||
|
||||
$key = 'test-idem-key-' . bin2hex(random_bytes(8));
|
||||
|
||||
$a = $this->withHeaders(['X-Idempotency-Key' => $key])
|
||||
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'First body']);
|
||||
$a->assertOk();
|
||||
$aId = $a->json('comment.id');
|
||||
|
||||
// Different body, same key — because the first write cached the id,
|
||||
// the second call returns it unchanged.
|
||||
$b = $this->withHeaders(['X-Idempotency-Key' => $key])
|
||||
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Different body']);
|
||||
$b->assertOk();
|
||||
$bId = $b->json('comment.id');
|
||||
|
||||
$this->assertSame($aId, $bId, 'Same idempotency key must return the same comment id.');
|
||||
}
|
||||
|
||||
// ─── Owner view exclusion ─────────────────────────────
|
||||
|
||||
public function test_owner_view_is_not_recorded(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get("/videos/{$video->id}");
|
||||
|
||||
$this->assertSame(0, DB::table('video_views')->where('video_id', $video->id)->count(),
|
||||
'Views by the video owner must not be counted.');
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────
|
||||
|
||||
private function makeUserAndVideo(): array
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
|
||||
$video = Video::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test video',
|
||||
'description'=> 'Hello',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
]);
|
||||
|
||||
return [$user, $video];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 2 — Performance and DOM. Guards against regressions in the shared
|
||||
* edit modal, owner-only menu items, thumbnail layout-shift attrs, and the
|
||||
* cursor-paginated feed endpoint.
|
||||
*/
|
||||
class Phase2PerfTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Shared edit modal: per-card copy is gone ─────
|
||||
|
||||
public function test_video_card_does_not_render_a_per_card_edit_modal(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-card', ['video' => $video])->render();
|
||||
|
||||
// The dead per-card modal used id="editVideoModal{slug}" and the class
|
||||
// "cute-edit-modal". If either reappears, we're back to shipping 100
|
||||
// extra lines per card.
|
||||
$this->assertStringNotContainsString('editVideoModal' . $video->getRouteKey(), $html);
|
||||
$this->assertStringNotContainsString('cute-edit-modal', $html);
|
||||
}
|
||||
|
||||
// ─── Owner-only menu items ────────────────────────
|
||||
|
||||
public function test_owner_sees_edit_delete_items_in_card_menu(): void
|
||||
{
|
||||
[$owner, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$html = view('components.video-card', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringContainsString('openEditVideoModal', $html);
|
||||
$this->assertStringContainsString('showDeleteModal', $html);
|
||||
$this->assertStringNotContainsString('notInterested', $html);
|
||||
$this->assertStringNotContainsString('dontRecommendChannel', $html);
|
||||
$this->assertStringNotContainsString('reportVideo', $html);
|
||||
}
|
||||
|
||||
public function test_non_owner_sees_report_items_but_not_edit(): void
|
||||
{
|
||||
[$owner, $video] = $this->makeUserAndVideo();
|
||||
$viewer = User::factory()->create([
|
||||
'username' => 'v'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$html = view('components.video-card', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringNotContainsString('openEditVideoModal', $html);
|
||||
$this->assertStringNotContainsString('showDeleteModal', $html);
|
||||
$this->assertStringContainsString('notInterested', $html);
|
||||
$this->assertStringContainsString('reportVideo', $html);
|
||||
}
|
||||
|
||||
// ─── Thumbnail layout-shift attributes ────────────
|
||||
|
||||
public function test_thumbnail_image_has_width_and_height(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-card', ['video' => $video])->render();
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<img[^>]*width="320"[^>]*height="180"/i',
|
||||
$html,
|
||||
'Thumbnail img must carry explicit width/height so the browser reserves layout space.'
|
||||
);
|
||||
$this->assertStringContainsString('loading="lazy"', $html);
|
||||
}
|
||||
|
||||
// ─── Cursor-paginated feed endpoint ───────────────
|
||||
|
||||
public function test_feed_more_returns_json_with_cards_and_next_cursor(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'f'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
// Twice the page size so the cursor must advance.
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
Video::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Vid ' . $i,
|
||||
'description' => '',
|
||||
'filename' => "users/x/videos/y{$i}/video.mp4",
|
||||
'path' => "users/x/videos/y{$i}/video.mp4",
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token' => bin2hex(random_bytes(8)),
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson(route('videos.feed.more', ['filter' => 'all']));
|
||||
$r->assertOk();
|
||||
$r->assertJsonStructure(['items', 'next_cursor', 'has_more']);
|
||||
$this->assertTrue($r->json('has_more'), 'With 30 videos and a 24-page size, has_more must be true.');
|
||||
$this->assertNotEmpty($r->json('next_cursor'));
|
||||
$this->assertStringContainsString('yt-video-card', (string) $r->json('items'));
|
||||
}
|
||||
|
||||
public function test_feed_more_unknown_filter_returns_empty_slice(): void
|
||||
{
|
||||
$this->getJson(route('videos.feed.more', ['filter' => 'nonsense']))
|
||||
->assertOk()
|
||||
->assertJson(['items' => '', 'has_more' => false]);
|
||||
}
|
||||
|
||||
// ─── Lazy reply-form template present ─────────────
|
||||
|
||||
public function test_comments_component_ships_the_reply_template_not_inline_forms(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
\App\Models\Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'top-level comment',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-comments', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringContainsString('id="ytc-reply-form-tpl"', $html);
|
||||
$this->assertStringContainsString('ytc-reply-slot', $html);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────
|
||||
|
||||
private function makeUserAndVideo(): array
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
$video = Video::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test video',
|
||||
'description'=> 'Hello',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token' => bin2hex(random_bytes(8)),
|
||||
]);
|
||||
return [$user, $video];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 3 — Feeds, search, trending.
|
||||
*
|
||||
* Guards against: trending window regressions, music feed opening on oldest,
|
||||
* search returning the wrong buckets, and the /videos/search → /search 301.
|
||||
*/
|
||||
class Phase3FeedsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Trending window ─────────────────────────────
|
||||
|
||||
public function test_trending_accepts_24_168_720_and_snaps_others_to_24(): void
|
||||
{
|
||||
$this->get(route('videos.trending', ['hours' => 24]))->assertOk();
|
||||
$this->get(route('videos.trending', ['hours' => 168]))->assertOk();
|
||||
$this->get(route('videos.trending', ['hours' => 720]))->assertOk();
|
||||
|
||||
// Garbage input must not crash; it snaps to 24.
|
||||
$this->get(route('videos.trending', ['hours' => 'abc']))->assertOk();
|
||||
$this->get(route('videos.trending', ['hours' => 99999]))->assertOk();
|
||||
}
|
||||
|
||||
public function test_trending_ranks_by_in_window_views_then_likes(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
|
||||
// Three videos — "A" has most views in last 24h, "B" has most likes
|
||||
// but no views, "C" has nothing. A should be first; B should NOT
|
||||
// appear (no in-window views → filtered out); C should not appear.
|
||||
$a = $this->makeVideo($owner, 'A');
|
||||
$b = $this->makeVideo($owner, 'B');
|
||||
$c = $this->makeVideo($owner, 'C');
|
||||
|
||||
$this->seedViews($a, 10);
|
||||
$this->seedViews($b, 0);
|
||||
$this->seedLikes($b, 5);
|
||||
$this->seedViews($c, 0);
|
||||
|
||||
$r = $this->get(route('videos.trending', ['hours' => 24]));
|
||||
$r->assertOk();
|
||||
|
||||
$html = $r->getContent();
|
||||
$this->assertStringContainsString('A', $html, 'Video A must appear — it has in-window views.');
|
||||
$this->assertStringNotContainsString('>B<', $html, 'Video B must not appear — zero in-window views.');
|
||||
}
|
||||
|
||||
// ─── Music feed newest-first ─────────────────────
|
||||
|
||||
public function test_music_feed_opens_on_newest_video(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
// created_at isn't in Video::$fillable (Eloquent auto-manages it),
|
||||
// so set it after the row exists.
|
||||
$old = $this->makeVideo($owner, 'OLD MUSIC', ['type' => 'music']);
|
||||
$old->created_at = now()->subDays(5); $old->save();
|
||||
$new = $this->makeVideo($owner, 'NEW MUSIC', ['type' => 'music']);
|
||||
$new->created_at = now()->subMinutes(5); $new->save();
|
||||
|
||||
$html = $this->get(route('videos.index', ['filter' => 'music']))->assertOk()->getContent();
|
||||
|
||||
$posNew = strpos($html, 'NEW MUSIC');
|
||||
$posOld = strpos($html, 'OLD MUSIC');
|
||||
$this->assertNotFalse($posNew);
|
||||
$this->assertNotFalse($posOld);
|
||||
$this->assertLessThan($posOld, $posNew, 'Newer music video must appear before the older one.');
|
||||
}
|
||||
|
||||
// ─── /videos/search → /search (301) ──────────────
|
||||
|
||||
public function test_old_videos_search_url_301_redirects_to_search(): void
|
||||
{
|
||||
$this->get('/videos/search?q=hello')
|
||||
->assertStatus(301)
|
||||
->assertRedirect('/search?q=hello');
|
||||
}
|
||||
|
||||
// ─── /search returns channel + playlist buckets ──
|
||||
|
||||
public function test_search_matches_video_channel_and_playlist(): void
|
||||
{
|
||||
$owner = User::factory()->create([
|
||||
'name' => 'Searchable Owner',
|
||||
'username' => 'searchable-channel-'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||
]);
|
||||
$video = $this->makeVideo($owner, 'SearchableVideoTitle', ['description' => 'nothing special']);
|
||||
$pl = Playlist::create([
|
||||
'user_id' => $owner->id,
|
||||
'name' => 'SearchablePlaylistName',
|
||||
'visibility' => 'public',
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
]);
|
||||
|
||||
// 1. Query that matches a video title
|
||||
$html = $this->get(route('search', ['q' => 'SearchableVideo']))->assertOk()->getContent();
|
||||
$this->assertStringContainsString('SearchableVideoTitle', $html);
|
||||
|
||||
// 2. Query that matches a channel name
|
||||
$html = $this->get(route('search', ['q' => 'Searchable Owner']))->assertOk()->getContent();
|
||||
$this->assertStringContainsString('Channels', $html);
|
||||
$this->assertStringContainsString('Searchable Owner', $html);
|
||||
|
||||
// 3. Query that matches a playlist name
|
||||
$html = $this->get(route('search', ['q' => 'SearchablePlaylist']))->assertOk()->getContent();
|
||||
$this->assertStringContainsString('Playlists', $html);
|
||||
$this->assertStringContainsString('SearchablePlaylistName', $html);
|
||||
}
|
||||
|
||||
public function test_empty_search_redirects_to_browse(): void
|
||||
{
|
||||
$this->get(route('search', ['q' => '']))
|
||||
->assertRedirect(route('videos.index'));
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────
|
||||
|
||||
private function makeUser(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeVideo(User $u, string $title, array $attrs = []): Video
|
||||
{
|
||||
return Video::create(array_merge([
|
||||
'user_id' => $u->id,
|
||||
'title' => $title,
|
||||
'description' => '',
|
||||
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token' => bin2hex(random_bytes(8)),
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
private function seedViews(Video $v, int $n): void
|
||||
{
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
DB::table('video_views')->insert([
|
||||
'video_id' => $v->id,
|
||||
'user_id' => null,
|
||||
'device_id' => 'dev-'.$i,
|
||||
'watched_at' => now()->subMinutes(10),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function seedLikes(Video $v, int $n): void
|
||||
{
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$u = $this->makeUser();
|
||||
DB::table('video_likes')->insert([
|
||||
'video_id' => $v->id,
|
||||
'user_id' => $u->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Support\HtmlSanitizer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 4 — Watch page.
|
||||
*
|
||||
* Guards against regressions in: the Up Next sidebar (anchor cards, weighted
|
||||
* recommender), the comment permalink + plural counts, the markdown-leak
|
||||
* rendering, and the download access gate for both MP4 and MP3.
|
||||
*/
|
||||
class Phase4WatchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Comment component ────────────────────────────
|
||||
|
||||
public function test_comment_header_uses_singular_count_for_one_comment(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'the only comment',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-comments', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringContainsString('1 Comment', $html);
|
||||
$this->assertStringNotContainsString('1 Comments', $html);
|
||||
}
|
||||
|
||||
public function test_comment_header_breaks_out_replies_from_top_level(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$parent = Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'top',
|
||||
]);
|
||||
Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'parent_id' => $parent->id,
|
||||
'body' => 'reply 1',
|
||||
]);
|
||||
Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'parent_id' => $parent->id,
|
||||
'body' => 'reply 2',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-comments', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringContainsString('1 Comment', $html, 'Top-level count is 1.');
|
||||
$this->assertStringContainsString('2 replies', $html, 'Replies are counted separately.');
|
||||
}
|
||||
|
||||
public function test_each_comment_timestamp_is_a_lc_permalink(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo();
|
||||
$c = Comment::create([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'hi',
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('components.video-comments', ['video' => $video])->render();
|
||||
|
||||
$this->assertStringContainsString('?lc=' . $c->id . '#comment-' . $c->id, $html);
|
||||
$this->assertStringContainsString('ytc-permalink', $html);
|
||||
}
|
||||
|
||||
// ─── Description markdown ────────────────────────
|
||||
|
||||
public function test_leaked_markdown_headers_render_as_html(): void
|
||||
{
|
||||
$out = HtmlSanitizer::render("Intro line\n## Letra\nla la\n### Chorus");
|
||||
|
||||
$this->assertStringContainsString('<h2>Letra</h2>', $out);
|
||||
$this->assertStringContainsString('<h3>Chorus</h3>', $out);
|
||||
$this->assertStringNotContainsString('## Letra', $out);
|
||||
$this->assertStringNotContainsString('### Chorus', $out);
|
||||
}
|
||||
|
||||
public function test_normal_plain_text_description_passes_through(): void
|
||||
{
|
||||
$out = HtmlSanitizer::render("hello world\nhow are you");
|
||||
|
||||
$this->assertStringContainsString('hello world', $out);
|
||||
$this->assertStringContainsString('how are you', $out);
|
||||
$this->assertStringNotContainsString('<h2>', $out);
|
||||
}
|
||||
|
||||
// ─── Download access gate ────────────────────────
|
||||
|
||||
public function test_disabled_download_returns_403_for_mp4(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
|
||||
|
||||
$this->get(route('videos.download', $video))->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_disabled_download_returns_403_for_mp3(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
|
||||
|
||||
$this->get(route('videos.downloadMp3', $video))->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_registered_download_denies_guests(): void
|
||||
{
|
||||
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'registered']);
|
||||
|
||||
$this->get(route('videos.download', $video))->assertStatus(403);
|
||||
}
|
||||
|
||||
// ─── Up Next anchor cards ─────────────────────────
|
||||
|
||||
public function test_up_next_sidebar_renders_anchor_cards_with_hrefs(): void
|
||||
{
|
||||
[$owner, $target] = $this->makeUserAndVideo();
|
||||
[, $rec] = $this->makeUserAndVideo();
|
||||
|
||||
$html = $this->get(route('videos.show', $target))->getContent();
|
||||
|
||||
$this->assertStringContainsString('<a class="sidebar-video-card"', $html,
|
||||
'Up Next entries must be anchor elements, not onclick divs.');
|
||||
$this->assertStringContainsString('data-rec-url=', $html,
|
||||
'data-rec-url hooks keep the SPA transition working for left-clicks.');
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────
|
||||
|
||||
private function makeUserAndVideo(array $attrs = []): array
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
$video = Video::create(array_merge([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'T '.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||
'description'=> '',
|
||||
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
], $attrs));
|
||||
return [$user, $video];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 5 — Shorts.
|
||||
*
|
||||
* Guards against regressions in the deep-link route, the Home shelf, and
|
||||
* the branded 404 fallback.
|
||||
*/
|
||||
class Phase5ShortsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /shorts/{id} deep-link ──────────────────────
|
||||
|
||||
public function test_shorts_deep_link_renders_feed_scrolled_to_target(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$short = $this->makeShort($owner, 'DEEP LINK SHORT');
|
||||
|
||||
$html = $this->get(route('videos.shorts.focused', $short))->assertOk()->getContent();
|
||||
|
||||
$this->assertStringContainsString('DEEP LINK SHORT', $html);
|
||||
// The in-page scroller is driven by data-id matching the start id.
|
||||
$this->assertStringContainsString('data-id="' . $short->id . '"', $html);
|
||||
$this->assertStringContainsString('scrollIntoView', $html);
|
||||
}
|
||||
|
||||
public function test_shorts_deep_link_404s_on_non_short(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$regular = Video::create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'not a short',
|
||||
'description'=> '',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'is_shorts' => false,
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
]);
|
||||
|
||||
$this->get('/shorts/' . Video::encodeId($regular->id))->assertStatus(404);
|
||||
}
|
||||
|
||||
// ─── Home shelf ──────────────────────────────────
|
||||
|
||||
public function test_home_shows_shorts_shelf_when_shorts_exist(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
$s = $this->makeShort($owner, 'SHELF SHORT');
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertStringContainsString('home-shorts-shelf', $html);
|
||||
$this->assertStringContainsString('SHELF SHORT', $html);
|
||||
$this->assertStringContainsString('/shorts/' . Video::encodeId($s->id), $html);
|
||||
}
|
||||
|
||||
public function test_home_omits_shorts_shelf_when_no_shorts(): void
|
||||
{
|
||||
$owner = $this->makeUser();
|
||||
// A regular video so the home feed renders, but no shorts.
|
||||
Video::create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'reg',
|
||||
'description'=> '',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'is_shorts' => false,
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
]);
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertStringNotContainsString('home-shorts-shelf', $html);
|
||||
}
|
||||
|
||||
// ─── Branded 404 ─────────────────────────────────
|
||||
|
||||
public function test_unknown_url_renders_branded_404(): void
|
||||
{
|
||||
$r = $this->get('/no-such-page')->assertStatus(404);
|
||||
|
||||
$r->assertSee('Error 404', false);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────
|
||||
|
||||
private function makeUser(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeShort(User $u, string $title): Video
|
||||
{
|
||||
return Video::create([
|
||||
'user_id' => $u->id,
|
||||
'title' => $title,
|
||||
'description'=> '',
|
||||
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'is_shorts' => true,
|
||||
'duration' => 45,
|
||||
'orientation'=> 'portrait',
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 6 — Channel / Studio / Live.
|
||||
*
|
||||
* Covers the handle-alias route, the composer being hidden in preview mode,
|
||||
* and the pluralised playlist meta.
|
||||
*/
|
||||
class Phase6ChannelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /@handle alias ─────────────────────────────
|
||||
|
||||
public function test_at_handle_url_opens_the_matching_channel(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Handle Test',
|
||||
'username' => 'handletest-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||
]);
|
||||
|
||||
$r = $this->get('/@' . $user->username)->assertOk();
|
||||
|
||||
$r->assertSee('Handle Test', false);
|
||||
}
|
||||
|
||||
public function test_at_handle_rejects_invalid_characters(): void
|
||||
{
|
||||
// Space in the handle must not match the /@ route.
|
||||
$this->get('/@not valid')->assertStatus(404);
|
||||
}
|
||||
|
||||
// ─── Preview-as-visitor hides composer ──────────
|
||||
|
||||
public function test_preview_as_visitor_hides_the_wall_composer(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'preview-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
// The composer form carries id="postForm" and action pointing at
|
||||
// posts.store — specific enough that we don't collide with the
|
||||
// "ch-composer-*" literals that appear in inline JS strings.
|
||||
$owner = $this->get('/channel/' . $user->username)->getContent();
|
||||
$this->assertStringContainsString('id="postForm"', $owner,
|
||||
'Owner should see the composer form in normal view.');
|
||||
|
||||
$preview = $this->get('/channel/' . $user->username . '?preview=1')->getContent();
|
||||
$this->assertStringNotContainsString('id="postForm"', $preview,
|
||||
'Preview-as-visitor must hide the composer form.');
|
||||
}
|
||||
|
||||
// ─── Playlist card: pluralise + hide stray "0.0" ──
|
||||
|
||||
public function test_playlist_card_pluralises_videos_and_hides_zero_views(): void
|
||||
{
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'pl-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||
]);
|
||||
$this->actingAs($owner);
|
||||
$pl = Playlist::create([
|
||||
'user_id' => $owner->id,
|
||||
'name' => 'Empty Playlist',
|
||||
'visibility' => 'public',
|
||||
'share_token'=> bin2hex(random_bytes(8)),
|
||||
]);
|
||||
|
||||
$html = $this->get('/channel/' . $owner->username)->getContent();
|
||||
|
||||
// The specific bug: the view-count row ends with
|
||||
// `<i class="bi bi-eye" aria-hidden="true"></i> 0.0`
|
||||
// for a zero-view playlist. The fix hides the whole row for zero
|
||||
// views, so the "bi-eye ...0.0" sequence should never appear.
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/bi-eye[^<]{0,200}0\.0/',
|
||||
$html,
|
||||
'Playlist meta must not emit "bi-eye … 0.0" for zero-view playlists.'
|
||||
);
|
||||
|
||||
// Plural must switch to singular for 1 video. Never "1 videos".
|
||||
$this->assertDoesNotMatchRegularExpression('/\b1 videos\b/i', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase 7 — Admin, a11y, consistency.
|
||||
*
|
||||
* Covers the skip-to-content link, icon-button aria-label coverage, the
|
||||
* server-side age gate on register, and the branded 404 title format.
|
||||
*/
|
||||
class Phase7AdminA11yTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Skip link + icon-button aria-labels ──────────
|
||||
|
||||
public function test_every_page_ships_the_skip_to_content_link(): void
|
||||
{
|
||||
$r = $this->get('/')->assertOk();
|
||||
|
||||
$r->assertSee('skip-to-content', false);
|
||||
$r->assertSee('href="#main"', false);
|
||||
}
|
||||
|
||||
public function test_header_icon_buttons_have_aria_labels(): void
|
||||
{
|
||||
$html = $this->get('/')->getContent();
|
||||
|
||||
// Hamburger menu
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<button[^>]+class="yt-menu-btn"[^>]+aria-label="[^"]+"/',
|
||||
$html,
|
||||
'The hamburger menu button must carry an aria-label.'
|
||||
);
|
||||
|
||||
// Mobile search toggle
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<button[^>]+yt-icon-btn[^>]+onclick="toggleMobileSearch[^>]+aria-label="[^"]+"/',
|
||||
$html,
|
||||
'The mobile search toggle must carry an aria-label.'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Age gate on registration ─────────────────────
|
||||
|
||||
public function test_registration_rejects_under_13(): void
|
||||
{
|
||||
$r = $this->post(route('register.store'), [
|
||||
'name' => 'Too Young',
|
||||
'email' => '[email protected]',
|
||||
'password' => 'HelloHello123!',
|
||||
'password_confirmation' => 'HelloHello123!',
|
||||
'birthday' => now()->subYears(10)->toDateString(),
|
||||
'gender' => 'male',
|
||||
'nationality' => 'BH',
|
||||
]);
|
||||
|
||||
$r->assertSessionHasErrors('birthday');
|
||||
$this->assertDatabaseMissing('users', ['email' => '[email protected]']);
|
||||
}
|
||||
|
||||
public function test_registration_accepts_at_least_13(): void
|
||||
{
|
||||
$r = $this->post(route('register.store'), [
|
||||
'name' => 'Just Old Enough',
|
||||
'email' => '[email protected]',
|
||||
'password' => 'HelloHello123!',
|
||||
'password_confirmation' => 'HelloHello123!',
|
||||
'birthday' => now()->subYears(14)->toDateString(),
|
||||
'gender' => 'male',
|
||||
'nationality' => 'BH',
|
||||
]);
|
||||
|
||||
$r->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
|
||||
}
|
||||
|
||||
// ─── Title format consistency ─────────────────────
|
||||
|
||||
public function test_branded_404_body_names_the_error(): void
|
||||
{
|
||||
// The exception handler in the testing env renders through a different
|
||||
// view resolution path than production (it bypasses layouts.app's head
|
||||
// section), so matching the <title> tag here is unreliable. The
|
||||
// body content is deterministic and sufficient to prove the branded
|
||||
// 404 is live — the production title format is smoke-tested via
|
||||
// curl in the deploy checklist.
|
||||
$this->get('/no-such-page')
|
||||
->assertStatus(404)
|
||||
->assertSee('Error 404', false);
|
||||
}
|
||||
|
||||
// ─── Gender select: no duplicate labels ───────────
|
||||
|
||||
public function test_settings_gender_select_has_distinct_options(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = $this->get('/channel/' . $user->username . '#settings')->getContent();
|
||||
|
||||
// The empty-value option used to say "Prefer not to say" too — one
|
||||
// label, two choices. It now says "Select…".
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<option value="">Select…<\/option>/u',
|
||||
$html,
|
||||
'Empty-value gender option should read "Select…", not "Prefer not to say".'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,39 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
/**
|
||||
* Belt-and-braces guard against a repeat of the 2026-10-21 incident, where
|
||||
* a test with the RefreshDatabase trait ran migrate:fresh against the
|
||||
* shared dev SQLite file and wiped every row.
|
||||
*
|
||||
* By the time setUp() runs we know the final DB config. If it points at
|
||||
* anything other than an in-memory SQLite (or a file inside the test
|
||||
* directory), we abort the whole run before any migration can execute.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->assertSafeTestDatabase();
|
||||
}
|
||||
|
||||
private function assertSafeTestDatabase(): void
|
||||
{
|
||||
$driver = config('database.default');
|
||||
$database = config("database.connections.{$driver}.database");
|
||||
|
||||
$allowed = $database === ':memory:'
|
||||
|| $database === null
|
||||
|| str_contains((string) $database, '/testing')
|
||||
|| str_contains((string) $database, '/tmp/')
|
||||
|| str_ends_with((string) $database, '.testing.sqlite');
|
||||
|
||||
if (! $allowed) {
|
||||
// Fatal, not an assertion failure — a wrong DB target has to stop
|
||||
// execution before Laravel can run even one query against it.
|
||||
fwrite(STDERR, "\n\n*** ABORTING TESTS: unsafe DB target '{$database}' on connection '{$driver}'. ***\n");
|
||||
fwrite(STDERR, "Set DB_CONNECTION=sqlite and DB_DATABASE=:memory: in .env.testing (or in your test's setUp before parent::setUp).\n\n");
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user