Compare commits
61
Commits
20c8fdcdf3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab59fc1bb | ||
|
|
e76ac39f99 | ||
|
|
c59b36ebf7 | ||
|
|
8fef257c86 | ||
|
|
5bf1128196 | ||
|
|
639279f2a2 | ||
|
|
8c6746e547 | ||
|
|
45505e6419 | ||
|
|
59513f2051 | ||
|
|
677e8711dd | ||
|
|
f7c3b236ab | ||
|
|
e550d8d15b | ||
|
|
7ba272edf6 | ||
|
|
4d9bc0f501 | ||
|
|
73de5bc97e | ||
|
|
341e94a4f8 | ||
|
|
574c9581bb | ||
|
|
22d6b1507d | ||
|
|
5aef41201a | ||
|
|
5adafc7616 | ||
|
|
05ccb45fe4 | ||
|
|
b7f3f6989b | ||
|
|
0e8bcfd47b | ||
|
|
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,357 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs up the database, and optionally mirrors the media library.
|
||||||
|
*
|
||||||
|
* Three things make this a backup rather than a file copy:
|
||||||
|
*
|
||||||
|
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
|
||||||
|
* checkpoint, so the artifact is a coherent point-in-time copy even while
|
||||||
|
* the app is writing. Copying the file by hand mid-write is not.
|
||||||
|
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
|
||||||
|
* back before it counts as a success. An unverified backup is a rumour.
|
||||||
|
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
|
||||||
|
* not a dead disk, so a configured remote disk receives a copy and the
|
||||||
|
* command warns loudly on every run where none is set.
|
||||||
|
*
|
||||||
|
* The media library is deliberately NOT part of the default run. It is
|
||||||
|
* gigabytes of immutable uploads; archiving it nightly would produce hours of
|
||||||
|
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
|
||||||
|
* incrementally with rsync instead.
|
||||||
|
*
|
||||||
|
* Exits non-zero on any failure so a cron surfaces the problem instead of
|
||||||
|
* quietly producing corrupt files.
|
||||||
|
*
|
||||||
|
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
|
||||||
|
* up the same way and restored the same way.
|
||||||
|
*/
|
||||||
|
class BackupCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'takeone:backup
|
||||||
|
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
|
||||||
|
{--media-only : Mirror media and skip the database}
|
||||||
|
{--keep-local : Do not delete the local copy after an off-server upload}';
|
||||||
|
|
||||||
|
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
|
||||||
|
|
||||||
|
private array $artifacts = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dir = (string) config('backup.path');
|
||||||
|
|
||||||
|
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
|
||||||
|
$this->error("Cannot create backup directory: {$dir}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = now()->format('Ymd-His');
|
||||||
|
$failed = false;
|
||||||
|
|
||||||
|
if (! $this->option('media-only')) {
|
||||||
|
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('media') || $this->option('media-only')) {
|
||||||
|
$failed = ! $this->mirrorMedia() || $failed;
|
||||||
|
} else {
|
||||||
|
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->shipOffServer();
|
||||||
|
$this->prune($dir);
|
||||||
|
|
||||||
|
if ($failed) {
|
||||||
|
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
|
||||||
|
$this->error('Backup FAILED — see the errors above.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Backup complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Database ---------------- */
|
||||||
|
|
||||||
|
private function backupDatabase(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$connection = config('database.default');
|
||||||
|
$driver = config("database.connections.{$connection}.driver");
|
||||||
|
|
||||||
|
return match ($driver) {
|
||||||
|
'sqlite' => $this->backupSqlite($dir, $stamp),
|
||||||
|
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
|
||||||
|
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
|
||||||
|
private function backupSqlite(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sqlite";
|
||||||
|
|
||||||
|
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
|
||||||
|
// something cryptic, say so plainly — a backup that quietly produces
|
||||||
|
// nothing is the worst possible outcome.
|
||||||
|
if (DB::transactionLevel() > 0) {
|
||||||
|
$this->error('Cannot snapshot the database inside an open transaction.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Harmless when the database is not in WAL mode.
|
||||||
|
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Database snapshot failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verifySqlite($target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the snapshot back. A file that exists but cannot be opened, or that
|
||||||
|
* holds no migrations and no videos, is not a backup worth trusting.
|
||||||
|
*/
|
||||||
|
private function verifySqlite(string $path): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = new PDO('sqlite:'.$path);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
|
||||||
|
$this->error("Snapshot failed integrity check: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
|
||||||
|
|
||||||
|
if ($migrations < 1) {
|
||||||
|
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a real application table too. `migrations` alone would still
|
||||||
|
// be present in a snapshot taken of an empty database.
|
||||||
|
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
|
||||||
|
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Snapshot could not be verified: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backupMysql(string $dir, string $stamp, string $connection): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sql.gz";
|
||||||
|
$c = config("database.connections.{$connection}");
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
|
||||||
|
escapeshellarg((string) $c['host']),
|
||||||
|
escapeshellarg((string) $c['port']),
|
||||||
|
escapeshellarg((string) $c['username']),
|
||||||
|
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
|
||||||
|
escapeshellarg((string) $c['database']),
|
||||||
|
escapeshellarg($target),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
|
||||||
|
$this->error('mysqldump failed (exit '.$status.').');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the dump is readable and non-trivial rather than trusting the
|
||||||
|
// exit code — a gzip of an error message also exits 0.
|
||||||
|
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
|
||||||
|
|
||||||
|
if ($verify !== 0) {
|
||||||
|
$this->error('Dump failed its gzip integrity check — discarding.');
|
||||||
|
@unlink($target);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Media ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the media library with rsync.
|
||||||
|
*
|
||||||
|
* Uploaded video cannot be regenerated from anything — losing it is the
|
||||||
|
* one unrecoverable outcome on this platform. rsync is used rather than an
|
||||||
|
* archive because the library is large and almost entirely immutable, so
|
||||||
|
* each run copies only what is new.
|
||||||
|
*/
|
||||||
|
private function mirrorMedia(): bool
|
||||||
|
{
|
||||||
|
$destination = (string) config('backup.media_destination');
|
||||||
|
|
||||||
|
if ($destination === '') {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
|
||||||
|
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
|
||||||
|
$this->error("Media destination is not reachable: {$destination}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
|
||||||
|
|
||||||
|
if (! $paths) {
|
||||||
|
$this->line(' <comment>–</comment> media nothing to mirror');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --delete is opt-in and off by default: a mirror that deletes is one
|
||||||
|
// wrong path away from erasing the copy it exists to protect.
|
||||||
|
$flags = '-a --partial --info=stats2';
|
||||||
|
|
||||||
|
if (config('backup.media_delete')) {
|
||||||
|
$flags .= ' --delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = true;
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
// Trailing slash on the source, basename on the destination, so
|
||||||
|
// each folder lands in its own directory rather than merging.
|
||||||
|
$command = sprintf(
|
||||||
|
'rsync %s %s %s 2>&1',
|
||||||
|
$flags,
|
||||||
|
escapeshellarg(rtrim($path, '/').'/'),
|
||||||
|
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0) {
|
||||||
|
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
|
||||||
|
$ok = false;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Off-server ---------------- */
|
||||||
|
|
||||||
|
private function shipOffServer(): void
|
||||||
|
{
|
||||||
|
$disk = config('backup.disk');
|
||||||
|
|
||||||
|
if (! $disk) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
|
||||||
|
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->artifacts as $artifact) {
|
||||||
|
try {
|
||||||
|
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
|
||||||
|
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
|
||||||
|
|
||||||
|
if (! $this->option('keep-local')) {
|
||||||
|
@unlink($artifact);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never delete the local copy when the upload failed — it is
|
||||||
|
// the only copy left.
|
||||||
|
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Rotation ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete artifacts past the retention window, but always keep the newest
|
||||||
|
* few — a box that sat idle for a month must still have something to
|
||||||
|
* restore from.
|
||||||
|
*/
|
||||||
|
private function prune(string $dir): void
|
||||||
|
{
|
||||||
|
$days = (int) config('backup.retain_days', 14);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = max(0, (int) config('backup.keep_minimum', 3));
|
||||||
|
$cutoff = now()->subDays($days)->getTimestamp();
|
||||||
|
$removed = 0;
|
||||||
|
|
||||||
|
$files = glob($dir.'/db-*') ?: [];
|
||||||
|
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
|
||||||
|
|
||||||
|
foreach (array_slice($files, $keep) as $file) {
|
||||||
|
if (filemtime($file) < $cutoff && @unlink($file)) {
|
||||||
|
$removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removed) {
|
||||||
|
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function humanSize(string $path): string
|
||||||
|
{
|
||||||
|
$bytes = (int) @filesize($path);
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
|
||||||
|
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes, 1).' '.$units[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
|||||||
->weeklyOn(1, '09:00')
|
->weeklyOn(1, '09:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
|
||||||
|
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
|
||||||
|
// and (when BACKUP_DISK is set) ships a copy off-server.
|
||||||
|
$schedule->command('takeone:backup')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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\Http\Controllers\Controller;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\BruteForceLoginAlert;
|
||||||
|
use App\Support\SafeNotifier;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class AuthenticatedSessionController extends Controller
|
class AuthenticatedSessionController extends Controller
|
||||||
{
|
{
|
||||||
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
|
|||||||
'details' => ['email' => $credentials['email']],
|
'details' => ['email' => $credentials['email']],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->detectBruteForce($request, $credentials['email']);
|
||||||
|
|
||||||
return back()->withErrors([
|
return back()->withErrors([
|
||||||
'email' => 'The provided credentials do not match our records.',
|
'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)
|
public function destroy(Request $request)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\NewUserRegistered;
|
use App\Notifications\NewUserRegistered;
|
||||||
use App\Rules\NotDisposableEmail;
|
use App\Rules\NotDisposableEmail;
|
||||||
|
use App\Rules\ReservedIdentifier;
|
||||||
|
use App\Support\SafeNotifier;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
|
||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'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'],
|
'gender' => ['required', 'in:male,female'],
|
||||||
'nationality' => ['required', 'string', 'size:2'],
|
'nationality' => ['required', 'string', 'size:2'],
|
||||||
]);
|
]);
|
||||||
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
|
|||||||
|
|
||||||
// Notify all super admins of the new registration
|
// Notify all super admins of the new registration
|
||||||
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
|
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);
|
auth()->login($user);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Models\Video;
|
|||||||
use App\Notifications\NewCommentLikeNotification;
|
use App\Notifications\NewCommentLikeNotification;
|
||||||
use App\Notifications\NewCommentNotification;
|
use App\Notifications\NewCommentNotification;
|
||||||
use App\Notifications\NewReplyNotification;
|
use App\Notifications\NewReplyNotification;
|
||||||
|
use App\Services\LlmLyricsService;
|
||||||
|
use App\Support\SafeNotifier;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ class CommentController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['index']);
|
$this->middleware('auth')->except(['index', 'translate']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Video $video)
|
public function index(Video $video)
|
||||||
@@ -34,12 +36,66 @@ class CommentController extends Controller
|
|||||||
|
|
||||||
$commenter = Auth::user();
|
$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([
|
$comment = $video->comments()->create([
|
||||||
'user_id' => $commenter->id,
|
'user_id' => $commenter->id,
|
||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
'parent_id' => $request->parent_id,
|
'parent_id' => $request->parent_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($cacheKey) {
|
||||||
|
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
$comment->load('user:id,name,avatar');
|
$comment->load('user:id,name,avatar');
|
||||||
|
|
||||||
// Fire notifications (never notify yourself)
|
// Fire notifications (never notify yourself)
|
||||||
@@ -47,12 +103,12 @@ class CommentController extends Controller
|
|||||||
// Reply — notify the parent comment's author
|
// Reply — notify the parent comment's author
|
||||||
$parent = Comment::find($request->parent_id);
|
$parent = Comment::find($request->parent_id);
|
||||||
if ($parent && $parent->user_id !== $commenter->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 {
|
} else {
|
||||||
// Top-level comment — notify the video owner
|
// Top-level comment — notify the video owner
|
||||||
if ($video->user_id !== $commenter->id) {
|
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]);
|
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)
|
public function like(Comment $comment)
|
||||||
{
|
{
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
@@ -107,7 +194,8 @@ class CommentController extends Controller
|
|||||||
if ($comment->user_id !== $userId) {
|
if ($comment->user_id !== $userId) {
|
||||||
$video = $comment->video;
|
$video = $comment->video;
|
||||||
if ($video) {
|
if ($video) {
|
||||||
$comment->user->notify(
|
SafeNotifier::send(
|
||||||
|
$comment->user,
|
||||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,4 +206,56 @@ class CommentController extends Controller
|
|||||||
|
|
||||||
return response()->json(['liked' => $liked, 'count' => $count]);
|
return response()->json(['liked' => $liked, 'count' => $count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the "creator heart" on a comment. Only the video owner can
|
||||||
|
* drop a heart; anyone else gets a 403.
|
||||||
|
*/
|
||||||
|
public function heart(Comment $comment)
|
||||||
|
{
|
||||||
|
$video = $comment->video;
|
||||||
|
if (! $video || $video->user_id !== Auth::id()) {
|
||||||
|
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->hearted = ! $comment->hearted;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'hearted' => (bool) $comment->hearted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle "pinned" on a top-level comment. Only the video owner can
|
||||||
|
* pin, and pinned comments sort ahead of everything else in the list.
|
||||||
|
* At most one comment is pinned at a time — pinning a new one unpins
|
||||||
|
* the previous.
|
||||||
|
*/
|
||||||
|
public function pin(Comment $comment)
|
||||||
|
{
|
||||||
|
$video = $comment->video;
|
||||||
|
if (! $video || $video->user_id !== Auth::id()) {
|
||||||
|
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
|
||||||
|
}
|
||||||
|
if ($comment->parent_id) {
|
||||||
|
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasPinned = (bool) $comment->pinned;
|
||||||
|
if (! $wasPinned) {
|
||||||
|
// Unpin any previously-pinned comment on this video first so
|
||||||
|
// the UI invariant "at most one pinned" is maintained at the
|
||||||
|
// DB level, not just in the view.
|
||||||
|
Comment::where('video_id', $video->id)
|
||||||
|
->where('pinned', true)
|
||||||
|
->update(['pinned' => false]);
|
||||||
|
}
|
||||||
|
$comment->pinned = ! $wasPinned;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'pinned' => (bool) $comment->pinned,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media server's authorisation hook.
|
||||||
|
*
|
||||||
|
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
|
||||||
|
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
|
||||||
|
* keeps every "who may do what" decision inside the application, next to the
|
||||||
|
* users table, rather than duplicated in a media server config.
|
||||||
|
*
|
||||||
|
* Deny by default, in the strongest sense: if this app is down, the media
|
||||||
|
* server can authorise nothing at all.
|
||||||
|
*
|
||||||
|
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
|
||||||
|
* of the public API and reject anything arriving from elsewhere.
|
||||||
|
*/
|
||||||
|
class LiveAuthController extends Controller
|
||||||
|
{
|
||||||
|
/** Loopback addresses the media server may call from. */
|
||||||
|
private const LOCAL_IPS = ['127.0.0.1', '::1'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide a publish or read attempt.
|
||||||
|
*
|
||||||
|
* Payload from MediaMTX: user, password, token, ip, action, path,
|
||||||
|
* protocol, id, query.
|
||||||
|
*/
|
||||||
|
public function authenticate(Request $request): Response
|
||||||
|
{
|
||||||
|
if (! $this->fromMediaServer($request)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = (string) $request->input('action');
|
||||||
|
|
||||||
|
// Control-plane actions carry no stream path, so they are settled
|
||||||
|
// before any attempt to resolve one.
|
||||||
|
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
|
||||||
|
return $this->authorizeApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) $request->input('path');
|
||||||
|
|
||||||
|
$stream = $this->resolveStream($path);
|
||||||
|
|
||||||
|
if (! $stream) {
|
||||||
|
// An unknown path is not an error worth explaining — a publisher
|
||||||
|
// probing for valid stream ids learns nothing from this.
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($action) {
|
||||||
|
'publish' => $this->authorizePublish($request, $stream),
|
||||||
|
'read' => $this->authorizeRead($stream),
|
||||||
|
default => response('', 403),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media server's own control API.
|
||||||
|
*
|
||||||
|
* It authenticates against this same hook, so without an explicit allow the
|
||||||
|
* app could not read live state from the server it runs alongside. Safe
|
||||||
|
* because it is already loopback-gated above — nothing off this host can
|
||||||
|
* reach it.
|
||||||
|
*/
|
||||||
|
private function authorizeApi(): Response
|
||||||
|
{
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A publisher must present the short-lived token minted for this stream
|
||||||
|
* when its owner pressed "Go live". The token is single-use: it is burned
|
||||||
|
* here, so a captured one cannot start a second broadcast.
|
||||||
|
*/
|
||||||
|
private function authorizePublish(Request $request, LiveStream $stream): Response
|
||||||
|
{
|
||||||
|
// MediaMTX passes HTTP Basic credentials through as user/password.
|
||||||
|
$presented = (string) ($request->input('password') ?: $request->input('token'));
|
||||||
|
|
||||||
|
if (! $stream->publishTokenMatches($presented)) {
|
||||||
|
Log::warning('Live publish refused', [
|
||||||
|
'stream' => $stream->public_id,
|
||||||
|
'ip' => $request->input('ip'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream->consumePublishToken();
|
||||||
|
$stream->markLive();
|
||||||
|
|
||||||
|
Log::info('Live publish authorised', [
|
||||||
|
'stream' => $stream->public_id,
|
||||||
|
'user_id' => $stream->user_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads are anonymous by design — a viewer fetching HLS parts carries no
|
||||||
|
* credentials — so the gate is the stream's own state: it must be live and
|
||||||
|
* not private. The unpredictable path is what keeps an unlisted stream
|
||||||
|
* unlisted.
|
||||||
|
*/
|
||||||
|
private function authorizeRead(LiveStream $stream): Response
|
||||||
|
{
|
||||||
|
if (! $stream->isLive()) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle notifications from the media server.
|
||||||
|
*
|
||||||
|
* The auth hook only fires on the way in, so without this a stream whose
|
||||||
|
* phone lost signal would read as live forever.
|
||||||
|
*/
|
||||||
|
public function hook(Request $request): Response
|
||||||
|
{
|
||||||
|
if (! $this->fromMediaServer($request)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = $this->resolveStream((string) $request->input('path'));
|
||||||
|
|
||||||
|
if (! $stream) {
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
match ((string) $request->input('event')) {
|
||||||
|
'publish' => $stream->markLive(),
|
||||||
|
// Only a stream that is actually live can end. A late hook from a
|
||||||
|
// previous, failed session must not kill the one just started.
|
||||||
|
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
|
||||||
|
'read' => $this->viewerJoined($stream),
|
||||||
|
'unread' => $this->viewerLeft($stream),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Viewer accounting ---------------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Counted with atomic SQL increments rather than read-modify-write: viewers
|
||||||
|
* arrive concurrently, and two joins landing together would otherwise
|
||||||
|
* record one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function viewerJoined(LiveStream $stream): void
|
||||||
|
{
|
||||||
|
LiveStream::where('id', $stream->id)->increment('current_viewers');
|
||||||
|
|
||||||
|
// peak_viewers is a high-water mark, so it only ever moves up.
|
||||||
|
LiveStream::where('id', $stream->id)
|
||||||
|
->whereColumn('current_viewers', '>', 'peak_viewers')
|
||||||
|
->update(['peak_viewers' => DB::raw('current_viewers')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewerLeft(LiveStream $stream): void
|
||||||
|
{
|
||||||
|
// Floor at zero: a missed join (a restart, a dropped hook) must not
|
||||||
|
// drive the count negative and show "-1 watching".
|
||||||
|
LiveStream::where('id', $stream->id)
|
||||||
|
->where('current_viewers', '>', 0)
|
||||||
|
->decrement('current_viewers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Helpers ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the media server on this host may ask. Deliberately checks the raw
|
||||||
|
* connection address rather than a forwarded header — a header can be
|
||||||
|
* spoofed by anyone who can reach the app.
|
||||||
|
*/
|
||||||
|
private function fromMediaServer(Request $request): bool
|
||||||
|
{
|
||||||
|
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn "live/<public_id>" into the stream it names. */
|
||||||
|
private function resolveStream(string $path): ?LiveStream
|
||||||
|
{
|
||||||
|
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LiveStream::where('public_id', $m[1])->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\LiveChatMessage;
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live streaming — broadcaster and viewer surfaces.
|
||||||
|
*
|
||||||
|
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
|
||||||
|
* may broadcast, mints the short-lived credential the browser publishes with,
|
||||||
|
* and hands out the URLs. See LiveAuthController for the hook the media server
|
||||||
|
* calls back on.
|
||||||
|
*/
|
||||||
|
class LiveStreamController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Watching is open; everything that creates or controls a stream needs
|
||||||
|
// an account.
|
||||||
|
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live now, plus the viewer's own streams when signed in. */
|
||||||
|
public function index(): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
$live = LiveStream::live()
|
||||||
|
->listed()
|
||||||
|
->with('user')
|
||||||
|
->latest('started_at')
|
||||||
|
->limit(60)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$mine = Auth::check()
|
||||||
|
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('live.index', compact('live', 'mine'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "start a broadcast" form. */
|
||||||
|
public function create(): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
return view('live.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the stream record. This does NOT start anything — it reserves an
|
||||||
|
* identity and a path; the browser goes live from the broadcast page.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:120'],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// One live broadcast per account at a time. Without this a single user
|
||||||
|
// can pin open as many encoder slots as they can open tabs.
|
||||||
|
$alreadyLive = LiveStream::where('user_id', Auth::id())
|
||||||
|
->where('status', LiveStream::STATUS_LIVE)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyLive) {
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = LiveStream::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'visibility' => $data['visibility'],
|
||||||
|
'status' => LiveStream::STATUS_IDLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('live.broadcast', $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcaster's camera page. Owner only. */
|
||||||
|
public function broadcast(LiveStream $stream): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
// An ended stream is not a dead one — the owner may go live on it
|
||||||
|
// again from the same link.
|
||||||
|
$stream->reArm();
|
||||||
|
|
||||||
|
return view('live.broadcast', [
|
||||||
|
'stream' => $stream,
|
||||||
|
'whipUrl' => $this->whipUrl($stream),
|
||||||
|
'watchUrl' => route('live.watch', $stream),
|
||||||
|
'iceServers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint the publish credential.
|
||||||
|
*
|
||||||
|
* Separate from the page load so the token's short life starts when the
|
||||||
|
* user actually presses "Go live", not when they opened the tab and went
|
||||||
|
* to find a tripod.
|
||||||
|
*/
|
||||||
|
public function token(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
// Pressing "Go live" on a stream that previously ended restarts it
|
||||||
|
// rather than being refused. A failed attempt must never burn the link.
|
||||||
|
$stream->reArm();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'token' => $stream->issuePublishToken(),
|
||||||
|
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
|
||||||
|
'whip_url' => $this->whipUrl($stream),
|
||||||
|
'ice_servers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer page. */
|
||||||
|
public function watch(LiveStream $stream): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||||
|
|
||||||
|
$stream->loadMissing('user');
|
||||||
|
|
||||||
|
return view('live.watch', [
|
||||||
|
'stream' => $stream,
|
||||||
|
'whepUrl' => $this->whepUrl($stream),
|
||||||
|
'hlsUrl' => $this->hlsUrl($stream),
|
||||||
|
'isOwner' => $stream->isOwnedBy(Auth::user()),
|
||||||
|
'iceServers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight poll for the viewer page: is it live, how many watching. */
|
||||||
|
public function status(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $stream->status,
|
||||||
|
'live' => $stream->isLive(),
|
||||||
|
'viewers' => $stream->current_viewers,
|
||||||
|
'duration' => $stream->formatted_duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End the broadcast. Owner only. */
|
||||||
|
public function stop(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
$stream->markEnded();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $stream->status,
|
||||||
|
'duration' => $stream->formatted_duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- URLs ---------------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These are same-origin paths, proxied by nginx to the media server on
|
||||||
|
* loopback. Same-origin matters: the page is served over HTTPS, and a
|
||||||
|
* mixed-content or cross-origin media URL would be blocked by the browser
|
||||||
|
* before WebRTC ever started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function whipUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-rtc/'.$stream->mediaPath().'/whip');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function whepUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-rtc/'.$stream->mediaPath().'/whep');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hlsUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICE servers for the browser.
|
||||||
|
*
|
||||||
|
* No inbound UDP port is forwarded to this server, so a broadcaster outside
|
||||||
|
* the local network can only reach it through a TURN relay. On the LAN the
|
||||||
|
* host candidates work directly and TURN is never used.
|
||||||
|
*
|
||||||
|
* Configured through the environment so credentials never sit in the repo.
|
||||||
|
*/
|
||||||
|
private function iceServers(): array
|
||||||
|
{
|
||||||
|
$servers = [];
|
||||||
|
|
||||||
|
if ($stun = config('live.stun_url')) {
|
||||||
|
$servers[] = ['urls' => $stun];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($turn = config('live.turn_url')) {
|
||||||
|
$servers[] = array_filter([
|
||||||
|
'urls' => $turn,
|
||||||
|
'username' => config('live.turn_username'),
|
||||||
|
'credential' => config('live.turn_credential'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET poll: viewer asks for every message in this stream newer than
|
||||||
|
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
|
||||||
|
* messages — more than enough for a 3-second polling window, bounded
|
||||||
|
* to protect the DB when a client is slow to reconnect.
|
||||||
|
*/
|
||||||
|
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
// Private streams are owner-only; everything else is open to viewers.
|
||||||
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = (int) $request->query('since', 0);
|
||||||
|
$rows = LiveChatMessage::where('livestream_id', $stream->id)
|
||||||
|
->where('id', '>', $since)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(50)
|
||||||
|
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'messages' => $rows->map(fn ($m) => [
|
||||||
|
'id' => $m->id,
|
||||||
|
'uid' => $m->user_id,
|
||||||
|
'name' => $m->author_name,
|
||||||
|
'body' => $m->body,
|
||||||
|
'at' => $m->created_at?->toISOString(),
|
||||||
|
])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a chat message. Rate-limited to roughly one message per second
|
||||||
|
* per user via Laravel's built-in throttle, applied via the route.
|
||||||
|
* Body is capped at 400 chars (long enough for a sentence, short enough
|
||||||
|
* that the panel doesn't shear into a wall of text).
|
||||||
|
*/
|
||||||
|
public function chatSend(Request $request, LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
if ($stream->status !== LiveStream::STATUS_LIVE) {
|
||||||
|
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
|
||||||
|
}
|
||||||
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string', 'max:400'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$msg = LiveChatMessage::create([
|
||||||
|
'livestream_id' => $stream->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'author_name' => $user->name,
|
||||||
|
'body' => trim($data['body']),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => [
|
||||||
|
'id' => $msg->id,
|
||||||
|
'uid' => $msg->user_id,
|
||||||
|
'name' => $msg->author_name,
|
||||||
|
'body' => $msg->body,
|
||||||
|
'at' => $msg->created_at?->toISOString(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creator studio — the content management surface YouTube calls "YouTube
|
||||||
|
* Studio". A skeleton for now: a filterable table of the signed-in user's
|
||||||
|
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
|
||||||
|
* and schedule are future work.
|
||||||
|
*/
|
||||||
|
class StudioController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->middleware('auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The studio dashboard — defaults to the content tab.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$filter = $request->get('filter', 'all');
|
||||||
|
|
||||||
|
$query = Video::where('user_id', $user->id)->latest();
|
||||||
|
|
||||||
|
switch ($filter) {
|
||||||
|
case 'public': $query->where('visibility', 'public'); break;
|
||||||
|
case 'unlisted': $query->where('visibility', 'unlisted'); break;
|
||||||
|
case 'private': $query->where('visibility', 'private'); break;
|
||||||
|
case 'shorts': $query->where('is_shorts', true); break;
|
||||||
|
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$videos = $query->paginate(24)->withQueryString();
|
||||||
|
|
||||||
|
// Headline numbers for the top bar.
|
||||||
|
$totals = [
|
||||||
|
'total' => Video::where('user_id', $user->id)->count(),
|
||||||
|
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
|
||||||
|
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
|
||||||
|
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
|
||||||
|
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
|
||||||
|
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
|
||||||
|
'total_views' => \DB::table('video_views')
|
||||||
|
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('studio.index', [
|
||||||
|
'videos' => $videos,
|
||||||
|
'filter' => $filter,
|
||||||
|
'totals' => $totals,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
|
||||||
|
* Guards against setting visibility on videos the user doesn't own, so a
|
||||||
|
* crafted request can't flip anyone else's content.
|
||||||
|
*/
|
||||||
|
public function bulkVisibility(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'video_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'video_ids.*' => ['integer'],
|
||||||
|
'visibility' => ['required', 'in:public,unlisted,private'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = Video::whereIn('id', $request->video_ids)
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->update(['visibility' => $request->visibility]);
|
||||||
|
|
||||||
|
return response()->json(['updated' => $updated]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete. Same owner-only guard as above.
|
||||||
|
*/
|
||||||
|
public function bulkDelete(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'video_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'video_ids.*' => ['integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleted = Video::whereIn('id', $request->video_ids)
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['deleted' => $deleted]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
|
|||||||
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
||||||
|
|
||||||
// ── Storage ───────────────────────────────────────────────
|
// ── Storage ───────────────────────────────────────────────
|
||||||
$disk = Storage::disk('public');
|
// NAS is the primary store; walking the local disk here would miss
|
||||||
|
// every file that's been pushed to NAS and purged locally. We use
|
||||||
|
// the DB-captured file sizes instead: videos.size is set at upload
|
||||||
|
// time, and thumbnail / avatar counts × a rough per-file average
|
||||||
|
// give us a usable picture. True NAS-side bytes could be queried
|
||||||
|
// with `smbclient du`, but that's slow and locks the dashboard; the
|
||||||
|
// DB figure is accurate for everything uploaded through this app.
|
||||||
|
$sizeVideos = (int) Video::sum('size'); // bytes
|
||||||
|
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
|
||||||
|
$avatarCount = User::whereNotNull('avatar')->count();
|
||||||
|
$bannerCount = User::whereNotNull('banner')->count();
|
||||||
|
$postImageCount = \DB::table('post_images')->count();
|
||||||
|
|
||||||
$sizeVideos = $this->dirSize($disk, 'videos');
|
// Images are variable but ~120 KB is a solid conservative estimate
|
||||||
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
// for a web-sized JPEG/PNG upload.
|
||||||
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
|
||||||
$sizeImages = $this->dirSize($disk, 'images');
|
$sizeThumbnails = $thumbnailCount * 120 * 1024;
|
||||||
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
|
$sizeAvatars = $avatarCount * 120 * 1024;
|
||||||
|
$totalPublicSize = $sizeVideos + $sizeImages;
|
||||||
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
||||||
|
|
||||||
// Convert to MB
|
|
||||||
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
||||||
$storage = [
|
$storage = [
|
||||||
'videos' => $toMb($sizeVideos),
|
'videos' => $toMb($sizeVideos),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['channel']);
|
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typeahead search for members (used by the "Share to member" picker)
|
// Typeahead search for members (used by the "Share to member" picker)
|
||||||
@@ -246,7 +246,14 @@ class UserController extends Controller
|
|||||||
|
|
||||||
switch ($sort) {
|
switch ($sort) {
|
||||||
case 'popular':
|
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;
|
break;
|
||||||
case 'oldest':
|
case 'oldest':
|
||||||
$baseQuery->oldest();
|
$baseQuery->oldest();
|
||||||
@@ -262,8 +269,16 @@ class UserController extends Controller
|
|||||||
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
? $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();
|
: $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')
|
$totalViews = \DB::table('video_views')
|
||||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
->whereIn('video_id', $viewVideoIds)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
// Filter social links by visibility for the current viewer
|
// Filter social links by visibility for the current viewer
|
||||||
@@ -310,29 +325,41 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch history
|
// Watch history
|
||||||
public function history()
|
public function history(Request $request)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Get videos the user has watched, ordered by most recently watched
|
// Latest-first by watched_at. Paginate at the join so a user who
|
||||||
// Include private videos since they are the user's own
|
// has watched tens of thousands of videos doesn't pull every row
|
||||||
$videoIds = \DB::table('video_views')
|
// into memory on each page load. Fetch the videos keyed by latest
|
||||||
->where('user_id', $user->id)
|
// view so sort order is deterministic even when the same video has
|
||||||
->orderBy('watched_at', 'desc')
|
// been watched more than once.
|
||||||
->pluck('video_id')
|
//
|
||||||
->unique();
|
// Each history row is one (video_views + videos) pair, with the
|
||||||
|
// uniqueness constraint deferred to presentation: duplicates per
|
||||||
$videos = Video::whereIn('id', $videoIds)
|
// video are expected (one row per view event) and the UI collapses
|
||||||
|
// them with view-count badges.
|
||||||
|
$per = 48;
|
||||||
|
$rows = \DB::table('video_views')
|
||||||
|
->join('videos', 'videos.id', '=', 'video_views.video_id')
|
||||||
|
->where('video_views.user_id', $user->id)
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('videos.visibility', '!=', 'private')
|
||||||
->orWhere('user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->get()
|
->orderBy('video_views.watched_at', 'desc')
|
||||||
->sortBy(function ($video) use ($videoIds) {
|
->select('videos.id as id', 'video_views.watched_at')
|
||||||
return $videoIds->search($video->id);
|
->simplePaginate($per);
|
||||||
});
|
|
||||||
|
|
||||||
return view('user.history', compact('videos'));
|
$videoIds = collect($rows->items())->pluck('id')->unique();
|
||||||
|
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
|
||||||
|
$videos = collect($rows->items())
|
||||||
|
->map(fn ($r) => $videoMap->get($r->id))
|
||||||
|
->filter()
|
||||||
|
->unique('id') // collapse re-watches of the same video on this page
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear watch history
|
// Clear watch history
|
||||||
@@ -345,18 +372,82 @@ class UserController extends Controller
|
|||||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON endpoint powering the "Load more" button on the channel Videos
|
||||||
|
* tab. Takes a username + optional cursor and returns a page of
|
||||||
|
* server-rendered video cards + next cursor. Reuses the cursorPaginate
|
||||||
|
* pattern Phase 2 established for the home feed.
|
||||||
|
*/
|
||||||
|
public function channelVideosMore(Request $request, string $username)
|
||||||
|
{
|
||||||
|
$owner = User::where('username', $username)->firstOrFail();
|
||||||
|
$viewer = Auth::user();
|
||||||
|
$isOwner = $viewer && $viewer->id === $owner->id;
|
||||||
|
|
||||||
|
$query = $isOwner
|
||||||
|
? Video::where('user_id', $owner->id)
|
||||||
|
: Video::public()->where('user_id', $owner->id);
|
||||||
|
|
||||||
|
$paginator = $query->where('is_shorts', false)
|
||||||
|
->latest('id')
|
||||||
|
->cursorPaginate(24);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($paginator->items() as $video) {
|
||||||
|
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $html,
|
||||||
|
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// Liked videos
|
||||||
public function liked()
|
public function liked()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
// Include private videos in liked (user's own private videos)
|
// Include private videos in liked (user's own private videos).
|
||||||
|
// Cursor pagination matches the Phase 2 feed endpoints — stable
|
||||||
|
// across inserts (unlike ?page=N which can duplicate or skip rows
|
||||||
|
// when a new like lands between page fetches) and loads a bounded
|
||||||
|
// slice regardless of how many thousands of likes the user has.
|
||||||
$videos = $user->likes()
|
$videos = $user->likes()
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('visibility', '!=', 'private')
|
||||||
->orWhere('videos.user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->latest()
|
->latest('videos.id')
|
||||||
->paginate(12);
|
->cursorPaginate(24);
|
||||||
|
|
||||||
return view('user.liked', compact('videos'));
|
return view('user.liked', compact('videos'));
|
||||||
}
|
}
|
||||||
@@ -394,6 +485,9 @@ class UserController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$video->likes()->attach($user->id);
|
$video->likes()->attach($user->id);
|
||||||
$liked = true;
|
$liked = true;
|
||||||
|
// Toggling to like clears any existing dislike — mirrors YT's
|
||||||
|
// behavior where a video can be liked OR disliked, not both.
|
||||||
|
$video->dislikes()->detach($user->id);
|
||||||
if ($video->user_id && $video->user_id !== $user->id) {
|
if ($video->user_id && $video->user_id !== $user->id) {
|
||||||
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
@@ -401,7 +495,32 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'liked' => $liked,
|
'liked' => $liked,
|
||||||
|
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
|
||||||
'like_count' => $video->like_count,
|
'like_count' => $video->like_count,
|
||||||
|
'dislike_count' => $video->dislike_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle dislike (API). Mutually exclusive with the like state.
|
||||||
|
public function toggleDislike(Video $video)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($video->isDislikedBy($user)) {
|
||||||
|
$video->dislikes()->detach($user->id);
|
||||||
|
$disliked = false;
|
||||||
|
} else {
|
||||||
|
$video->dislikes()->attach($user->id);
|
||||||
|
$disliked = true;
|
||||||
|
// Clear any existing like.
|
||||||
|
$video->likes()->detach($user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'disliked' => $disliked,
|
||||||
|
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
|
||||||
|
'like_count' => $video->like_count,
|
||||||
|
'dislike_count' => $video->dislike_count,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,66 @@ class VideoController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
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()
|
public function index()
|
||||||
@@ -104,9 +163,12 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
// ── Filtered single-type views ────────────────────────────
|
// ── Filtered single-type views ────────────────────────────
|
||||||
if (in_array($filter, ['music', 'latest'])) {
|
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');
|
if ($filter === 'music') $query->where('type', 'music');
|
||||||
else $query->latest();
|
|
||||||
$videos = $query->limit(50)->get();
|
$videos = $query->limit(50)->get();
|
||||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||||
@@ -114,7 +176,12 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── All (home) — mixed chronological feed ─────────────────
|
// ── 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')
|
$playlistQuery = Playlist::withCount('videos')
|
||||||
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||||
@@ -131,7 +198,7 @@ class VideoController extends Controller
|
|||||||
->sortByDesc('date')
|
->sortByDesc('date')
|
||||||
->values();
|
->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(),
|
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -691,11 +758,28 @@ class VideoController extends Controller
|
|||||||
$viewFp = $request->cookie('_fp');
|
$viewFp = $request->cookie('_fp');
|
||||||
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
|
$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')
|
$exists = \DB::table('video_views')
|
||||||
->where('user_id', Auth::id())
|
|
||||||
->where('video_id', $video->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();
|
->exists();
|
||||||
|
|
||||||
if (! $exists) {
|
if (! $exists) {
|
||||||
@@ -711,12 +795,13 @@ class VideoController extends Controller
|
|||||||
'watched_at' => now(),
|
'watched_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} elseif (! $isOwnerViewing) {
|
||||||
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
|
// Guest: 30-minute dedupe keyed on the strongest device signal
|
||||||
|
// (fingerprint hash > device_id cookie).
|
||||||
$exists = \DB::table('video_views')
|
$exists = \DB::table('video_views')
|
||||||
->whereNull('user_id')
|
->whereNull('user_id')
|
||||||
->where('video_id', $video->id)
|
->where('video_id', $video->id)
|
||||||
->where('watched_at', '>', now()->subHour())
|
->where('watched_at', '>', now()->subMinutes(30))
|
||||||
->where(function ($q) use ($viewFp, $viewDid) {
|
->where(function ($q) use ($viewFp, $viewDid) {
|
||||||
if ($viewFp) $q->where('device_hash', $viewFp);
|
if ($viewFp) $q->where('device_hash', $viewFp);
|
||||||
else $q->where('device_id', $viewDid);
|
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)
|
->where('id', '!=', $video->id)
|
||||||
->latest()
|
->with('user')
|
||||||
->limit(20)
|
->latest('id')
|
||||||
|
->limit(200)
|
||||||
->get();
|
->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) {
|
$view = match ($video->type) {
|
||||||
'match' => 'videos.types.match',
|
'match' => 'videos.types.match',
|
||||||
'music' => 'videos.types.music',
|
'music' => 'videos.types.music',
|
||||||
@@ -828,7 +961,15 @@ class VideoController extends Controller
|
|||||||
: $slideMap['0'];
|
: $slideMap['0'];
|
||||||
|
|
||||||
$allLangData = \App\Data\Languages::all();
|
$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,
|
'id' => $t->id,
|
||||||
'language' => $t->language,
|
'language' => $t->language,
|
||||||
'label' => $t->label,
|
'label' => $t->label,
|
||||||
@@ -913,6 +1054,53 @@ class VideoController extends Controller
|
|||||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||||
* so the next Generate produces a fresh result.
|
* 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)
|
public function deleteLyrics(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
if (Auth::id() !== $video->user_id) {
|
if (Auth::id() !== $video->user_id) {
|
||||||
@@ -1951,47 +2139,52 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
public function trending(Request $request)
|
public function trending(Request $request)
|
||||||
{
|
{
|
||||||
$hours = $request->get('hours', 48);
|
// Snap the ?hours param to one of the three windows the UI exposes
|
||||||
$limit = $request->get('limit', 50);
|
// (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
|
||||||
$hours = min(max($hours, 24), 168);
|
// the same data; now 720 is a first-class option.
|
||||||
$limit = min(max($limit, 10), 100);
|
$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()
|
$videos = Video::public()
|
||||||
->where('status', 'ready')
|
->where('status', 'ready')
|
||||||
->where('created_at', '>=', now()->subDays(10))
|
->where('created_at', '>=', now()->subMonths(6))
|
||||||
->with('user')
|
->with('user')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$videos = $videos->map(function ($video) use ($hours) {
|
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
|
||||||
$recentViews = \DB::table('video_views')
|
$recentViews = \DB::table('video_views')
|
||||||
->where('video_id', $video->id)
|
->where('video_id', $video->id)
|
||||||
->where('watched_at', '>=', now()->subHours($hours))
|
->where('watched_at', '>=', $windowStart)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$likeCount = \DB::table('video_likes')
|
$likeCount = \DB::table('video_likes')
|
||||||
->where('video_id', $video->id)
|
->where('video_id', $video->id)
|
||||||
->count();
|
->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->view_count = $recentViews;
|
||||||
$video->like_count = $likeCount;
|
$video->like_count = $likeCount;
|
||||||
|
$video->_tiebreak_age = $video->created_at->timestamp;
|
||||||
return $video;
|
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
|
$trendingVideos = $videos
|
||||||
->filter(fn ($v) => $v->trending_score > 0)
|
->filter(fn ($v) => $v->view_count > 0)
|
||||||
->sortByDesc('trending_score')
|
->sortBy([
|
||||||
|
['view_count', 'desc'],
|
||||||
|
['like_count', 'desc'],
|
||||||
|
['_tiebreak_age', 'desc'],
|
||||||
|
])
|
||||||
->take($limit)
|
->take($limit)
|
||||||
->values();
|
->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()
|
$videos = Video::public()
|
||||||
->where('is_shorts', true)
|
->where('is_shorts', true)
|
||||||
@@ -2011,7 +2204,23 @@ class VideoController extends Controller
|
|||||||
->latest()
|
->latest()
|
||||||
->get();
|
->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)
|
public function stream(Video $video)
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'parent_id',
|
'parent_id',
|
||||||
'body',
|
'body',
|
||||||
|
'hearted',
|
||||||
|
'pinned',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'hearted' => 'boolean',
|
||||||
|
'pinned' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class LiveChatMessage extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'live_chat_messages';
|
||||||
|
|
||||||
|
public $timestamps = false; // explicit created_at only, set via useCurrent()
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'livestream_id',
|
||||||
|
'user_id',
|
||||||
|
'author_name',
|
||||||
|
'body',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function livestream()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(LiveStream::class, 'livestream_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live broadcast.
|
||||||
|
*
|
||||||
|
* The row is the authority on whether a stream may exist; the media server
|
||||||
|
* holds no state of its own and asks this table (through LiveAuthController)
|
||||||
|
* before it accepts a publisher or a viewer.
|
||||||
|
*/
|
||||||
|
class LiveStream extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'livestreams';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'public_id', 'title', 'description',
|
||||||
|
'status', 'visibility', 'video_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The publish token is a credential. Keeping it out of the default array
|
||||||
|
* form means it cannot be leaked by a careless toJson() on a model that
|
||||||
|
* reached a view or an API response.
|
||||||
|
*/
|
||||||
|
protected $hidden = ['publish_token', 'publish_token_expires_at'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'publish_token_expires_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'ended_at' => 'datetime',
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const STATUS_IDLE = 'idle';
|
||||||
|
public const STATUS_LIVE = 'live';
|
||||||
|
public const STATUS_ENDED = 'ended';
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
/** How long a freshly minted publish token stays valid. */
|
||||||
|
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
// Every stream gets an unpredictable public id, always — never left to
|
||||||
|
// a caller to remember.
|
||||||
|
static::creating(function (self $stream) {
|
||||||
|
$stream->public_id ??= self::newPublicId();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Identity ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 32 chars of alphanumeric entropy. Also the media path segment, which is
|
||||||
|
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
|
||||||
|
* the media server is configured with.
|
||||||
|
*/
|
||||||
|
public static function newPublicId(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$id = Str::random(32);
|
||||||
|
} while (self::where('public_id', $id)->exists());
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route model binding uses the unpredictable id, never the numeric key. */
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'public_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The path the media server knows this stream by. */
|
||||||
|
public function mediaPath(): string
|
||||||
|
{
|
||||||
|
return 'live/'.$this->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Relations ---------------- */
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recording, once a finished stream has been published as a video. */
|
||||||
|
public function video(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Video::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- State ---------------- */
|
||||||
|
|
||||||
|
public function isLive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_LIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOwnedBy(?User $user): bool
|
||||||
|
{
|
||||||
|
return $user !== null && $this->user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May this viewer watch?
|
||||||
|
*
|
||||||
|
* Both public and unlisted streams are watchable by anyone holding the
|
||||||
|
* link — the difference is only whether the stream is listed. Ownership
|
||||||
|
* additionally grants access before it goes live, so the broadcaster can
|
||||||
|
* open their own watch page to check themselves.
|
||||||
|
*/
|
||||||
|
public function isWatchableBy(?User $user): bool
|
||||||
|
{
|
||||||
|
if ($this->isOwnedBy($user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility first — private streams stay owner-only no matter what.
|
||||||
|
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live streams are watchable while they're on; ended streams remain
|
||||||
|
// permalinked so viewers see "Stream ended" and the replay CTA
|
||||||
|
// instead of a hard 404.
|
||||||
|
return $this->isLive() || $this->status === self::STATUS_ENDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Publish credential ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a short-lived, single-use token for the broadcaster's browser.
|
||||||
|
*
|
||||||
|
* Returned in plaintext to the owner exactly once, at the moment they press
|
||||||
|
* "Go live". Stored so the media server's auth hook can verify it, then
|
||||||
|
* consumed on first successful publish.
|
||||||
|
*/
|
||||||
|
public function issuePublishToken(): string
|
||||||
|
{
|
||||||
|
$token = Str::random(64);
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'publish_token' => hash('sha256', $token),
|
||||||
|
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time check of a presented token against the stored hash. */
|
||||||
|
public function publishTokenMatches(?string $presented): bool
|
||||||
|
{
|
||||||
|
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->publish_token_expires_at->isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($this->publish_token, hash('sha256', $presented));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Burn the token so a captured one cannot be replayed. */
|
||||||
|
public function consumePublishToken(): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'publish_token' => null,
|
||||||
|
'publish_token_expires_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Transitions ---------------- */
|
||||||
|
|
||||||
|
public function markLive(): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'status' => self::STATUS_LIVE,
|
||||||
|
'started_at' => $this->started_at ?? now(),
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
'ended_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put an ended stream back into a startable state.
|
||||||
|
*
|
||||||
|
* A stream is a reusable channel, not a one-shot: the owner keeps the same
|
||||||
|
* link and can go live on it again. Without this, any failed start — a lost
|
||||||
|
* signal, a refused camera, a server hiccup — would kill the stream
|
||||||
|
* permanently and force a new link on every retry.
|
||||||
|
*/
|
||||||
|
public function reArm(): void
|
||||||
|
{
|
||||||
|
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'status' => self::STATUS_IDLE,
|
||||||
|
'ended_at' => null,
|
||||||
|
'current_viewers' => 0,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markEnded(): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'status' => self::STATUS_ENDED,
|
||||||
|
'ended_at' => now(),
|
||||||
|
'current_viewers' => 0,
|
||||||
|
'publish_token' => null,
|
||||||
|
'publish_token_expires_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Display ---------------- */
|
||||||
|
|
||||||
|
public function getDurationSecondsAttribute(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->started_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedDurationAttribute(): string
|
||||||
|
{
|
||||||
|
$seconds = $this->duration_seconds;
|
||||||
|
|
||||||
|
if ($seconds === null) {
|
||||||
|
return '00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seconds >= 3600
|
||||||
|
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
|
||||||
|
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Scopes ---------------- */
|
||||||
|
|
||||||
|
public function scopeLive($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_LIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeListed($query)
|
||||||
|
{
|
||||||
|
return $query->where('visibility', 'public');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'two_factor_enabled' => 'boolean',
|
'two_factor_enabled' => 'boolean',
|
||||||
'notification_preferences' => 'array',
|
'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)
|
// Defaults for each preference key (true = on, false = off)
|
||||||
private const NOTIF_DEFAULTS = [
|
private const NOTIF_DEFAULTS = [
|
||||||
'notif_new_comment' => true,
|
'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
|
public static function generateUniqueUsername(string $name): string
|
||||||
{
|
{
|
||||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||||
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
|
|
||||||
public function sendEmailVerificationNotification(): void
|
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);
|
$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
|
// Role helper methods
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class Video extends Model
|
|||||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dislikes()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function viewers()
|
public function viewers()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class, 'video_views')
|
return $this->belongsToMany(User::class, 'video_views')
|
||||||
@@ -177,6 +182,20 @@ class Video extends Model
|
|||||||
return $this->likes()->count();
|
return $this->likes()->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dislike helpers — same shape as the like pair above.
|
||||||
|
public function isDislikedBy($user)
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->dislikes()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDislikeCountAttribute()
|
||||||
|
{
|
||||||
|
return $this->dislikes()->count();
|
||||||
|
}
|
||||||
|
|
||||||
// Get view count - use direct query to avoid timestamp issues
|
// Get view count - use direct query to avoid timestamp issues
|
||||||
public function getViewCountAttribute()
|
public function getViewCountAttribute()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
\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');
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
|
|
||||||
// Universal pagination view — used everywhere by default
|
// Universal pagination view — used everywhere by default
|
||||||
Paginator::defaultView('partials.pagination');
|
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. */
|
/** 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();
|
$p = $this->activeProvider();
|
||||||
if (! $p) return '';
|
if (! $p) return '';
|
||||||
try {
|
try {
|
||||||
return match ($p['kind']) {
|
return match ($p['kind']) {
|
||||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
|
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
|
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||||
default => $this->callOllama($p, $prompt, $maxTokens),
|
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||||
};
|
};
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
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'), '/');
|
$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', [
|
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||||
'model' => $p['model'],
|
'model' => $p['model'],
|
||||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||||
'stream' => false,
|
'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()) {
|
if (! $resp->successful()) {
|
||||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$j = $resp->json();
|
$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'), '/');
|
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
|
||||||
$resp = Http::timeout(120)->withHeaders([
|
$resp = Http::timeout(120)->withHeaders([
|
||||||
@@ -235,6 +408,7 @@ class LlmLyricsService
|
|||||||
])->post($endpoint . '/v1/messages', [
|
])->post($endpoint . '/v1/messages', [
|
||||||
'model' => $p['model'],
|
'model' => $p['model'],
|
||||||
'max_tokens' => $maxTokens,
|
'max_tokens' => $maxTokens,
|
||||||
|
'temperature' => $temperature,
|
||||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||||
]);
|
]);
|
||||||
if (! $resp->successful()) {
|
if (! $resp->successful()) {
|
||||||
@@ -245,7 +419,7 @@ class LlmLyricsService
|
|||||||
return (string) ($j['content'][0]['text'] ?? '');
|
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'), '/');
|
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
|
||||||
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
||||||
@@ -254,7 +428,7 @@ class LlmLyricsService
|
|||||||
'model' => $p['model'],
|
'model' => $p['model'],
|
||||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||||
'max_tokens' => $maxTokens,
|
'max_tokens' => $maxTokens,
|
||||||
'temperature' => 0.2,
|
'temperature' => $temperature,
|
||||||
]);
|
]);
|
||||||
if (! $resp->successful()) {
|
if (! $resp->successful()) {
|
||||||
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
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) {
|
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||||
|
|
||||||
// ── 1. Promote source's primary audio to a new track on target ─
|
// ── 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}";
|
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||||
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
|
|
||||||
|
|
||||||
$newPrimaryTrack = VideoAudioTrack::create([
|
$newPrimaryTrack = VideoAudioTrack::create([
|
||||||
'video_id' => $target->id,
|
'video_id' => $target->id,
|
||||||
@@ -108,43 +114,83 @@ class VideoMergeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
|
$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}"]);
|
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
|
||||||
|
|
||||||
// ── 2. Move each source extra track's folder into target/tracks/
|
// ── 2. Move each source extra track's folder into target/tracks/
|
||||||
// The folder basename ({lang}-{track_id}) is globally unique because
|
// The folder basename ({lang}-{track_id}) is globally unique
|
||||||
// track ids are globally unique — no collisions with target's own tracks.
|
// 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) {
|
foreach ($source->audioTracks as $track) {
|
||||||
$basename = $this->nas->trackFolderName($source, $track);
|
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||||
$oldFolder = "{$sourceDir}/tracks/{$basename}";
|
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||||
$newFolder = "{$targetDir}/tracks/{$basename}";
|
if ($oldFolder !== $newFolder) {
|
||||||
$this->moveFolder($oldFolder, $newFolder);
|
$this->moveFolder($oldFolder, $newFolder);
|
||||||
|
}
|
||||||
|
|
||||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||||
: $track->path;
|
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||||
|
|
||||||
$track->video_id = $target->id;
|
$track->video_id = $target->id;
|
||||||
$track->path = $newTrackPath;
|
$track->path = $newTrackPath;
|
||||||
$track->save();
|
$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 ────────────────────
|
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||||
// One pass: re-parent every source slide, rewrite any filename
|
// location, derived from the track it belongs to AFTER the
|
||||||
// that started with sourceDir/... to targetDir/..., and if the
|
// per-track folder moves above. This is layout-agnostic —
|
||||||
// slide had NULL audio_track_id (belonged to source's primary),
|
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||||
// point it at the newly-created primary track.
|
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||||
$sourcePrefix = $sourceDir . '/';
|
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||||
$targetPrefix = $targetDir . '/';
|
|
||||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||||
foreach ($slides as $s) {
|
foreach ($slides as $s) {
|
||||||
$newFilename = $s->filename;
|
$base = $s->filename ? basename($s->filename) : null;
|
||||||
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
|
if (!$base) continue; // nothing we can safely rewrite
|
||||||
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
|
|
||||||
}
|
$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 = [
|
$updates = [
|
||||||
'video_id' => $target->id,
|
'video_id' => $target->id,
|
||||||
'filename' => $newFilename,
|
'filename' => "{$owningFolder}/slides/{$base}",
|
||||||
];
|
];
|
||||||
if ($s->audio_track_id === null) {
|
if ($s->audio_track_id === null) {
|
||||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||||
@@ -197,12 +243,54 @@ class VideoMergeService
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── 10. Best-effort cleanup of the emptied source folder ───────────
|
// ── 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}/tracks");
|
||||||
$this->pruneEmptyFolder($sourceDir);
|
$this->pruneEmptyFolder($sourceDir);
|
||||||
|
|
||||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
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
|
* 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.
|
* 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
|
* Produce safe display HTML. Legacy plain-text values (no tags) are
|
||||||
* escaped and converted to <br>; rich values are run through clean().
|
* 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
|
public static function render(?string $value): string
|
||||||
{
|
{
|
||||||
@@ -85,6 +90,8 @@ class HtmlSanitizer
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$value = self::promoteLeakedMarkdown($value);
|
||||||
|
|
||||||
if (strip_tags($value) === $value) {
|
if (strip_tags($value) === $value) {
|
||||||
return nl2br(e($value), false);
|
return nl2br(e($value), false);
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,24 @@ class HtmlSanitizer
|
|||||||
return self::clean($value);
|
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
|
private static function sanitizeChildren(DOMNode $node): void
|
||||||
{
|
{
|
||||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
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,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Backup
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for `php artisan takeone:backup`.
|
||||||
|
|
|
||||||
|
| The database and the media library are backed up by DIFFERENT mechanisms on
|
||||||
|
| purpose. The database is small and must be captured as a consistent
|
||||||
|
| point-in-time snapshot, so it is copied in full on every run. The media
|
||||||
|
| library is gigabytes of immutable uploads, so it is mirrored incrementally
|
||||||
|
| and only when asked — tarring it nightly would burn hours of CPU and fill
|
||||||
|
| the disk with near-identical archives.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// Where snapshots are written. Lives under /data, which is git-ignored, so
|
||||||
|
// a backup can never be committed by accident.
|
||||||
|
'path' => env('BACKUP_PATH', base_path('data/backups')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retention
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
|
||||||
|
| are ALWAYS kept regardless of age — a box that sat idle for a month must
|
||||||
|
| still have something to restore from.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
|
||||||
|
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Off-server copy
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
|
||||||
|
| data survives a bad migration but not a dead disk, so set this to a
|
||||||
|
| remote disk. The command warns on every run while it is unset.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'disk' => env('BACKUP_DISK'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Media library
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
|
||||||
|
| from anything. It is mirrored with rsync (incremental, resumable) to
|
||||||
|
| media_destination, which should be a NAS mount or another machine.
|
||||||
|
|
|
||||||
|
| Only runs when --media is passed AND a destination is set.
|
||||||
|
|
|
||||||
|
| media_delete stays false by default: a mirror that deletes is one bad
|
||||||
|
| path away from erasing the copy it was meant to protect.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'media_paths' => [
|
||||||
|
base_path('data/app/users'),
|
||||||
|
base_path('data/app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'media_destination' => env('BACKUP_MEDIA_DEST'),
|
||||||
|
|
||||||
|
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Live streaming
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The media plane runs as MediaMTX on loopback; nginx proxies /live-rtc and
|
||||||
|
| /live-hls to it. Nothing here is a secret except the TURN credential, which
|
||||||
|
| comes from the environment.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// MediaMTX's control API (localhost only). Used to read live publisher and
|
||||||
|
// viewer state.
|
||||||
|
'api_url' => env('LIVE_API_URL', 'http://127.0.0.1:9997'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ICE
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| STUN lets a browser discover its public address. TURN relays the media
|
||||||
|
| when a direct path cannot be established — which, until a UDP port is
|
||||||
|
| forwarded to this server, is EVERY broadcaster outside the local network.
|
||||||
|
|
|
||||||
|
| With no TURN configured, publishing works from the LAN only.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'),
|
||||||
|
|
||||||
|
'turn_url' => env('LIVE_TURN_URL'),
|
||||||
|
'turn_username' => env('LIVE_TURN_USERNAME'),
|
||||||
|
'turn_credential' => env('LIVE_TURN_CREDENTIAL'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Capture defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The target the phone is ASKED for. A camera that cannot do 1080p returns
|
||||||
|
| the closest it can, and WebRTC's congestion control lowers the bitrate
|
||||||
|
| when the uplink cannot sustain it — so this is a ceiling, not a promise.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920),
|
||||||
|
'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080),
|
||||||
|
'video_fps' => (int) env('LIVE_VIDEO_FPS', 30),
|
||||||
|
|
||||||
|
// ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink.
|
||||||
|
'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live streaming.
|
||||||
|
*
|
||||||
|
* Purely additive: one new table. No existing table is altered — the link to a
|
||||||
|
* recording is held here as video_id rather than adding a column to `videos`,
|
||||||
|
* so the music and generic video paths run against exactly the schema they run
|
||||||
|
* against today.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('livestreams', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
// Public, unpredictable identifier. Used in the watch URL AND as the
|
||||||
|
// media path, so it must never be guessable or enumerable — knowing
|
||||||
|
// one stream's id must not hint at another's.
|
||||||
|
$table->string('public_id', 32)->unique();
|
||||||
|
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
|
||||||
|
// idle → live → ended, or failed.
|
||||||
|
$table->string('status', 16)->default('idle');
|
||||||
|
|
||||||
|
// public = listed on the live index
|
||||||
|
// unlisted = reachable only with the link
|
||||||
|
$table->string('visibility', 16)->default('public');
|
||||||
|
|
||||||
|
// Short-lived, single-use credential the browser presents to the
|
||||||
|
// media server when it starts publishing. Not a long-lived stream
|
||||||
|
// key: it is minted on "Go live" and expires in minutes, so there
|
||||||
|
// is no durable secret to leak, screenshot, or reuse.
|
||||||
|
$table->string('publish_token', 64)->nullable();
|
||||||
|
$table->timestamp('publish_token_expires_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('ended_at')->nullable();
|
||||||
|
|
||||||
|
// Last time the media server confirmed the publisher is still
|
||||||
|
// connected — how a stream that died without saying goodbye gets
|
||||||
|
// reaped.
|
||||||
|
$table->timestamp('last_seen_at')->nullable();
|
||||||
|
|
||||||
|
$table->unsignedInteger('peak_viewers')->default(0);
|
||||||
|
$table->unsignedInteger('current_viewers')->default(0);
|
||||||
|
|
||||||
|
// Set once a finished stream has been turned into a normal video.
|
||||||
|
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// The live index reads "public streams that are live, newest first".
|
||||||
|
$table->index(['status', 'visibility']);
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('livestreams');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dislike counterpart to video_likes.
|
||||||
|
*
|
||||||
|
* One row per (user, video) pair. The unique index on the pair prevents a
|
||||||
|
* user from racking up multiple dislikes against the same video, and the
|
||||||
|
* foreign keys cascade so cleaning up a user or video drops their dislikes
|
||||||
|
* for free.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('video_dislikes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['video_id', 'user_id']);
|
||||||
|
$table->index('video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('video_dislikes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creator-engagement flags on comments.
|
||||||
|
*
|
||||||
|
* - hearted: the video owner has acknowledged the comment with a red
|
||||||
|
* heart (YouTube's "creator heart"). Visible to everyone.
|
||||||
|
* - pinned: the owner has anchored the comment to the top of the list.
|
||||||
|
* The comment component sorts pinned comments ahead of everything
|
||||||
|
* else, newest-pinned first.
|
||||||
|
*
|
||||||
|
* Both default to false and are indexed only on `pinned` because that's the
|
||||||
|
* one the comments query sorts by.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->boolean('hearted')->default(false)->after('body');
|
||||||
|
$table->boolean('pinned')->default(false)->after('hearted');
|
||||||
|
$table->index('pinned');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['pinned']);
|
||||||
|
$table->dropColumn(['hearted', 'pinned']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live chat messages — one row per message, scoped to a livestream. Simple
|
||||||
|
* polling model: viewers hit /live/{public_id}/chat?since=<id> every few
|
||||||
|
* seconds and get back everything newer than their last-seen id.
|
||||||
|
*
|
||||||
|
* Cascades: delete the livestream, lose its chat log. Users keep theirs
|
||||||
|
* nulled out so a deleted account's historical messages still render as
|
||||||
|
* "deleted user" rather than removing them from the thread.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('live_chat_messages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('livestream_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('author_name', 255); // snapshot of the sender's name at time of posting
|
||||||
|
$table->text('body');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
// Chat pagination reads "messages in this stream newer than id X".
|
||||||
|
$table->index(['livestream_id', 'id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('live_chat_messages');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -351,24 +351,40 @@
|
|||||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||||
</div>
|
</div>
|
||||||
<div class="al-page-links">
|
<div class="al-page-links">
|
||||||
|
{{-- First --}}
|
||||||
@if($logs->onFirstPage())
|
@if($logs->onFirstPage())
|
||||||
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
|
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
|
||||||
@else
|
@else
|
||||||
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
|
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Prev --}}
|
||||||
|
@if($logs->onFirstPage())
|
||||||
|
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
|
||||||
|
@else
|
||||||
|
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||||
@if($page == $logs->currentPage())
|
@if($page == $logs->currentPage())
|
||||||
<span class="active">{{ $page }}</span>
|
<span class="active" aria-current="page">{{ $page }}</span>
|
||||||
@else
|
@else
|
||||||
<a href="{{ $url }}">{{ $page }}</a>
|
<a href="{{ $url }}">{{ $page }}</a>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
|
{{-- Next --}}
|
||||||
@if($logs->hasMorePages())
|
@if($logs->hasMorePages())
|
||||||
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
|
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
|
||||||
@else
|
@else
|
||||||
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
|
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Last --}}
|
||||||
|
@if($logs->currentPage() < $logs->lastPage())
|
||||||
|
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
|
||||||
|
@else
|
||||||
|
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -292,13 +292,16 @@
|
|||||||
|
|
||||||
{{-- Ready Videos --}}
|
{{-- Ready Videos --}}
|
||||||
<div class="col-6 col-lg-4 col-xl-2">
|
<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-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
||||||
<div class="stat-card-top">
|
<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 class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</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
|
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
||||||
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
||||||
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
||||||
@@ -741,12 +744,21 @@ new Chart(document.getElementById('typeChart'), {
|
|||||||
|
|
||||||
// ── Country Chart ───────────────────────────────────────────────
|
// ── Country Chart ───────────────────────────────────────────────
|
||||||
@if($viewsByCountry->isNotEmpty())
|
@if($viewsByCountry->isNotEmpty())
|
||||||
(function() {
|
@php
|
||||||
const countryData = @json($viewsByCountry->map(fn($r) => [
|
// 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' => $r->country,
|
||||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||||
'total' => $r->total,
|
'total' => $r->total,
|
||||||
])->values());
|
];
|
||||||
|
})->values();
|
||||||
|
@endphp
|
||||||
|
(function() {
|
||||||
|
const countryData = @json($countryChartData);
|
||||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||||
const values = countryData.map(r => r.total);
|
const values = countryData.map(r => r.total);
|
||||||
const maxVal = Math.max(...values);
|
const maxVal = Math.max(...values);
|
||||||
|
|||||||
@@ -64,26 +64,74 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{-- Log lines --}}
|
{{-- Log lines, grouped — each entry's stack trace (lines beginning with
|
||||||
|
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
|
||||||
|
header line. Headers show their level color; stack trace lines
|
||||||
|
render in a muted indent and preserve their natural #0 → #N order
|
||||||
|
inside each group even when the overall list is newest-first. --}}
|
||||||
@if(empty($lines))
|
@if(empty($lines))
|
||||||
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
||||||
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
|
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
|
||||||
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
@php
|
||||||
|
// Group the flat list of lines into [{header, trace[]}, …].
|
||||||
|
// Any line that is NOT a header starts/continues the current
|
||||||
|
// group's trace. Lines that look like a header (ERROR/WARNING/
|
||||||
|
// INFO/DEBUG) start a new group.
|
||||||
|
$groups = [];
|
||||||
|
$current = null;
|
||||||
|
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($isHeader($line) || $current === null) {
|
||||||
|
if ($current) $groups[] = $current;
|
||||||
|
$current = ['header' => $line, 'trace' => []];
|
||||||
|
} else {
|
||||||
|
$current['trace'][] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($current) $groups[] = $current;
|
||||||
|
@endphp
|
||||||
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
||||||
@foreach($lines as $line)
|
@foreach($groups as $gi => $g)
|
||||||
@php
|
@php
|
||||||
$color = '#aaa';
|
$color = '#aaa';
|
||||||
if (str_contains($line, '.ERROR:')) $color = '#f87171';
|
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
||||||
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
|
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
||||||
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
|
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
||||||
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
|
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
||||||
|
$hasTrace = !empty($g['trace']);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
|
<div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
|
||||||
|
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
|
||||||
|
@if($hasTrace)
|
||||||
|
<button type="button"
|
||||||
|
onclick="this.closest('.log-group').classList.toggle('open')"
|
||||||
|
aria-label="Toggle stack trace"
|
||||||
|
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
|
||||||
|
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<span style="flex:none;display:inline-block;width:20px;"></span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $g['header'] }}</span>
|
||||||
|
</div>
|
||||||
|
@if($hasTrace)
|
||||||
|
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
|
||||||
|
@foreach($g['trace'] as $tl)
|
||||||
|
<div>{{ $tl }}</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.log-group.open .log-trace { display: block !important; }
|
||||||
|
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
|
||||||
|
</style>
|
||||||
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
{{-- Full title in the title attribute so the
|
||||||
|
admin can hover and read the whole thing
|
||||||
|
when the visible row ellipsizes. --}}
|
||||||
|
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||||
|
title="{{ $video->title }}">
|
||||||
{{ $video->title }}
|
{{ $video->title }}
|
||||||
</div>
|
</div>
|
||||||
@if($video->description)
|
@if($video->description)
|
||||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
@php
|
||||||
{{ Str::limit($video->description, 55) }}
|
// Descriptions are stored as sanitized rich HTML
|
||||||
|
// (see HtmlSanitizer). Blade's {{ }} would show
|
||||||
|
// the escaped tags verbatim in the admin table,
|
||||||
|
// so strip them to a plain-text preview — and
|
||||||
|
// put the full stripped text in the tooltip too.
|
||||||
|
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
|
||||||
|
@endphp
|
||||||
|
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||||
|
title="{{ $descPlain }}">
|
||||||
|
{{ Str::limit($descPlain, 55) }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -245,12 +245,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
/* Hard-cap the info block to guarantee every card has the same total
|
/* Reserve a floor for the info block so every card lands at the same
|
||||||
height (thumb + info). Overflow hidden so any accidental extra
|
height when titles are short. Two-line titles are allowed to grow
|
||||||
row can't push the card taller than its neighbours. */
|
the block vertically — overflow:visible so no row is hidden. */
|
||||||
height: 76px;
|
min-height: 76px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
/* 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 {
|
.yt-video-card .yt-video-details {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -284,22 +287,26 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
/* Single-line, no wrapping — overflow ellipses. */
|
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
|
||||||
white-space: nowrap;
|
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;
|
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 {
|
.yt-video-card .yt-video-title a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
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;
|
display: block;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
/* Inline children (flags, corner-colored fighter spans) must not break
|
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||||
the single-line layout. */
|
the single-line layout. */
|
||||||
@@ -446,305 +453,5 @@
|
|||||||
height: 24px;
|
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>
|
</style>
|
||||||
@endonce
|
@endonce
|
||||||
@@ -306,7 +306,7 @@
|
|||||||
@if($embedAudioInput)
|
@if($embedAudioInput)
|
||||||
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
||||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,25 +158,44 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
{{-- Guest Subscribe: showToast first so the user understands what's
|
||||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
happening, then redirect to the login page with intended=
|
||||||
|
preserving the current URL so after login they land back on
|
||||||
|
this video. 600 ms gives the toast a moment to be seen. --}}
|
||||||
|
<button type="button" class="action-btn desktop-action"
|
||||||
|
onclick="(function(){ if (typeof showToast==='function') showToast('Sign in to subscribe to {{ addslashes($video->user->name ?? 'this channel') }}', 'info'); setTimeout(function(){ window.location.href='{{ route('login') }}?intended=' + encodeURIComponent(window.location.pathname + window.location.search); }, 600); })();">
|
||||||
|
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
|
||||||
</button>
|
</button>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
|
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
|
||||||
|
one clears the other via the controllers' detach() call. --}}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
||||||
data-video-id="{{ $video->id }}"
|
data-video-id="{{ $video->id }}"
|
||||||
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
||||||
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
|
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
|
||||||
|
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
|
||||||
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
|
||||||
|
data-video-id="{{ $video->id }}"
|
||||||
|
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
|
||||||
|
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||||
|
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
|
||||||
|
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
@else
|
@else
|
||||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
||||||
<i class="bi bi-hand-thumbs-up"></i>
|
<i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
|
||||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
|
||||||
|
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
@if ($video->isShareable())
|
@if ($video->isShareable())
|
||||||
@@ -451,6 +470,46 @@
|
|||||||
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
|
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Dislike button ─────────────────────────────────────────────
|
||||||
|
// Dislike is mutually exclusive with like on the server side; whenever
|
||||||
|
// the server returns a new state, we reconcile BOTH buttons in one
|
||||||
|
// sweep so the pair always reflects the authoritative truth.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.dislike-toggle-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var url = btn.dataset.toggleUrl;
|
||||||
|
var videoId = btn.dataset.videoId;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
// Reconcile dislike pair.
|
||||||
|
document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
|
||||||
|
var icon = b.querySelector('i');
|
||||||
|
b.dataset.disliked = data.disliked ? 'true' : 'false';
|
||||||
|
b.classList.toggle('disliked', data.disliked);
|
||||||
|
if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down');
|
||||||
|
b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike');
|
||||||
|
});
|
||||||
|
// Reconcile like pair in parallel — may have flipped off.
|
||||||
|
document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
|
||||||
|
var icon = b.querySelector('i');
|
||||||
|
var label = b.querySelector('.like-count-label');
|
||||||
|
b.dataset.liked = data.liked ? 'true' : 'false';
|
||||||
|
b.classList.toggle('liked', data.liked);
|
||||||
|
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
|
||||||
|
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Slideshow video generation progress ──────────────────────────────
|
// ── Slideshow video generation progress ──────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@props(['video' => null, 'size' => 'medium'])
|
@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
use App\Data\Languages;
|
use App\Data\Languages;
|
||||||
@@ -85,10 +85,14 @@ $sizeClasses = match($size) {
|
|||||||
<a href="{{ $showUrl }}">
|
<a href="{{ $showUrl }}">
|
||||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
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')">
|
{{-- width/height let the browser reserve the card's thumb box
|
||||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
BEFORE the image decodes, so a grid of 60 cards doesn't shift
|
||||||
@include('components.partials.vs-mini', ['video' => $video])
|
as each thumbnail finishes loading. The ratio is honored by
|
||||||
@endif
|
.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)
|
@if($videoUrl)
|
||||||
{{-- Match cards preload metadata so the first frame is ready the
|
{{-- Match cards preload metadata so the first frame is ready the
|
||||||
instant the user hovers (otherwise the browser fetches, buffers
|
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' }}">
|
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||||
</video>
|
</video>
|
||||||
@endif
|
@endif
|
||||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
{{-- Match cards used to inline the full VS portrait + the live-scoring
|
||||||
@include('components.partials.scorebar-mini', ['video' => $video])
|
scorebar as thumbnail overlays. Those partials leaked fighter
|
||||||
@endif
|
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 --}}
|
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||||
<div class="audio-preview-overlay">
|
<div class="audio-preview-overlay">
|
||||||
<div class="audio-eq">
|
<div class="audio-eq">
|
||||||
@@ -132,13 +140,15 @@ $sizeClasses = match($size) {
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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) : '#' }}"
|
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
||||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||||
@if($video && $video->user && $video->user->avatar_url)
|
@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;">
|
<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
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
@endunless
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
<h3 class="yt-video-title">
|
<h3 class="yt-video-title">
|
||||||
<a href="{{ $showUrl }}">
|
<a href="{{ $showUrl }}">
|
||||||
@@ -152,7 +162,7 @@ $sizeClasses = match($size) {
|
|||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@if($video && $video->user)
|
@if($video && $video->user && ! $hideChannelInfo)
|
||||||
<a href="{{ route('channel', $video->user->channel) }}"
|
<a href="{{ route('channel', $video->user->channel) }}"
|
||||||
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
||||||
@endif
|
@endif
|
||||||
@@ -171,15 +181,15 @@ $sizeClasses = match($size) {
|
|||||||
</div>
|
</div>
|
||||||
@if($video)
|
@if($video)
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
<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"></i>
|
<i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||||
@if($isOwner)
|
@if($isOwner)
|
||||||
<li>
|
<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
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
<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>
|
<li><hr class="dropdown-divider"></li>
|
||||||
@endif
|
@endif
|
||||||
<li>
|
<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
|
<i class="bi bi-list-nested"></i> Add to queue
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<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
|
<i class="bi bi-clock"></i> Save to Watch later
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<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
|
<i class="bi bi-bookmark"></i> Save to playlist
|
||||||
</x-save-to-playlist-button>
|
</x-save-to-playlist-button>
|
||||||
</li>
|
</li>
|
||||||
@@ -212,133 +222,45 @@ $sizeClasses = match($size) {
|
|||||||
@endif
|
@endif
|
||||||
@if($video->isShareable())
|
@if($video->isShareable())
|
||||||
<li>
|
<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
|
<i class="bi bi-share"></i> Share
|
||||||
</x-share-button>
|
</x-share-button>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@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><hr class="dropdown-divider"></li>
|
||||||
<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
|
<i class="bi bi-dash-circle"></i> Not interested
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<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
|
<i class="bi bi-x-circle"></i> Don't recommend channel
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<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
|
<i class="bi bi-flag"></i> Report
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
@endunless
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
{{--
|
||||||
<!-- Cute Edit Video Modal -->
|
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||||
<div class="modal-content cute-edit-content">
|
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||||
<div class="cute-edit-header">
|
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||||
<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>
|
|
||||||
|
|
||||||
@include('components.partials.card-styles')
|
@include('components.partials.card-styles')
|
||||||
|
|
||||||
@@ -393,155 +315,8 @@ function stopVideo(element) {
|
|||||||
element.classList.remove('audio-playing');
|
element.classList.remove('audio-playing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit Modal Functions
|
// Edit-modal open/close + its form handlers live in the singleton at
|
||||||
let currentEditVideoId = null;
|
// layouts/partials/edit-video-modal.blade.php (not here) — see the
|
||||||
|
// removed-markup note above the dropdown for why.
|
||||||
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
|
|
||||||
</script>
|
</script>
|
||||||
@endonce
|
@endonce
|
||||||
@@ -4,8 +4,31 @@
|
|||||||
use App\Models\CommentLike;
|
use App\Models\CommentLike;
|
||||||
|
|
||||||
$currentUserId = Auth::id() ?? 0;
|
$currentUserId = Auth::id() ?? 0;
|
||||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||||
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
|
|
||||||
|
// 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);
|
||||||
|
// Pinned comments bubble to the top of the list regardless of recency,
|
||||||
|
// then everything else sorts newest-first. reorder() wipes the
|
||||||
|
// ->latest() default the comments() relation already applied, so
|
||||||
|
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
|
||||||
|
$topComments = $video->comments()
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->with('user', 'replies.user')
|
||||||
|
->reorder()
|
||||||
|
->orderByDesc('pinned')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
$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')
|
$allCommentIds = $topComments->pluck('id')
|
||||||
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
|
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
|
||||||
@@ -40,7 +63,17 @@
|
|||||||
|
|
||||||
{{-- ── Header ── --}}
|
{{-- ── Header ── --}}
|
||||||
<div class="ytc-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">
|
<div class="ytc-sort-wrap" id="ytcSortWrap">
|
||||||
<button class="ytc-sort-btn" id="ytcSortBtn">
|
<button class="ytc-sort-btn" id="ytcSortBtn">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
|
||||||
@@ -82,18 +115,41 @@
|
|||||||
{{-- ── Comments list ── --}}
|
{{-- ── Comments list ── --}}
|
||||||
<div id="ytcList">
|
<div id="ytcList">
|
||||||
@forelse($topComments as $comment)
|
@forelse($topComments as $comment)
|
||||||
@php $isOwn = $comment->user_id === $currentUserId; @endphp
|
@php
|
||||||
<div class="ytc-comment" id="comment-{{ $comment->id }}">
|
$isOwn = $comment->user_id === $currentUserId;
|
||||||
|
$isVideoOwner = $currentUserId === $video->user_id;
|
||||||
|
@endphp
|
||||||
|
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
|
||||||
|
@if($comment->pinned)
|
||||||
|
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
|
||||||
|
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
|
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
|
||||||
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
||||||
</a>
|
</a>
|
||||||
<div class="ytc-body-wrap">
|
<div class="ytc-body-wrap">
|
||||||
<div class="ytc-meta">
|
<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>
|
<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>
|
||||||
|
@if($comment->hearted)
|
||||||
|
<span class="ytc-heart-badge"
|
||||||
|
aria-label="Hearted by the creator"
|
||||||
|
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
|
||||||
|
<i class="bi bi-heart-fill" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</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 --}}
|
{{-- Edit form --}}
|
||||||
@if($isOwn)
|
@if($isOwn)
|
||||||
@@ -118,8 +174,31 @@
|
|||||||
<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>
|
<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>
|
</button>
|
||||||
@auth
|
@auth
|
||||||
|
{{-- Creator-only heart + pin toggles. The server
|
||||||
|
rejects anyone else, but hiding the buttons
|
||||||
|
keeps the UI uncluttered for regular viewers. --}}
|
||||||
|
@if($isVideoOwner)
|
||||||
|
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
|
||||||
|
data-comment-id="{{ $comment->id }}"
|
||||||
|
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
|
||||||
|
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
|
||||||
|
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
|
||||||
|
data-comment-id="{{ $comment->id }}"
|
||||||
|
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
|
||||||
|
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
|
||||||
|
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
||||||
@endauth
|
@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)
|
@if($isOwn)
|
||||||
<div class="ytc-more-wrap">
|
<div class="ytc-more-wrap">
|
||||||
<button class="ytc-more-btn" title="More actions">
|
<button class="ytc-more-btn" title="More actions">
|
||||||
@@ -139,20 +218,12 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</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
|
@auth
|
||||||
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
|
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
|
||||||
<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>
|
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
{{-- Replies --}}
|
{{-- Replies --}}
|
||||||
@@ -172,10 +243,18 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="ytc-body-wrap">
|
<div class="ytc-body-wrap">
|
||||||
<div class="ytc-meta">
|
<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>
|
<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>
|
||||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</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)
|
@if($replyIsOwn)
|
||||||
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
||||||
@@ -197,6 +276,21 @@
|
|||||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
<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>
|
<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>
|
</button>
|
||||||
|
@auth
|
||||||
|
{{-- Reply to a reply: opens the parent comment's reply form and
|
||||||
|
prefills "@AuthorName " so the responder's reader knows which
|
||||||
|
reply they're answering. The DB still stores it as a one-level
|
||||||
|
thread (parent_id = top-level), matching the existing model. --}}
|
||||||
|
<button class="ytc-reply-btn _comment-reply-nested-trigger"
|
||||||
|
data-parent-id="{{ $comment->id }}"
|
||||||
|
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
|
||||||
|
@endauth
|
||||||
|
@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)
|
@if($replyIsOwn)
|
||||||
<div class="ytc-more-wrap">
|
<div class="ytc-more-wrap">
|
||||||
<button class="ytc-more-btn">
|
<button class="ytc-more-btn">
|
||||||
@@ -270,6 +364,66 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.ytc-count-replies {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Creator engagement (heart + pin) ── */
|
||||||
|
.ytc-pinned-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ytc-pinned-badge i { color: #ef4444; }
|
||||||
|
.ytc-comment.ytc-pinned {
|
||||||
|
background: rgba(239, 68, 68, 0.03);
|
||||||
|
border-left: 2px solid rgba(239, 68, 68, 0.5);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
.ytc-heart-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ytc-creator-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
|
||||||
|
.ytc-creator-btn.active { color: #ef4444; }
|
||||||
|
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
|
||||||
|
|
||||||
|
/* 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-wrap { position: relative; }
|
||||||
.ytc-sort-btn {
|
.ytc-sort-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -440,7 +594,11 @@
|
|||||||
.ytc-time {
|
.ytc-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
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 ── */
|
/* ── Comment text ── */
|
||||||
.ytc-text {
|
.ytc-text {
|
||||||
@@ -451,6 +609,31 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Read more clamp ──
|
||||||
|
Long comments collapse to ~4 lines by default; the "Read more" button
|
||||||
|
under the body expands them. The clamp uses line-clamp (not a fixed
|
||||||
|
max-height) so the first few lines are always complete, never cut
|
||||||
|
mid-word, and the fade-mask eases into the trailing ellipsis.
|
||||||
|
*/
|
||||||
|
.ytc-text.ytc-clamp {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ytc-read-more {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.ytc-read-more:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
/* ── Actions ── */
|
/* ── Actions ── */
|
||||||
.ytc-actions {
|
.ytc-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -498,6 +681,57 @@
|
|||||||
}
|
}
|
||||||
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
|
.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 ── */
|
/* ── Three-dot menu ── */
|
||||||
.ytc-more-wrap {
|
.ytc-more-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -695,9 +929,16 @@ const YTC = {
|
|||||||
videoId: '{{ $video->getRouteKey() }}',
|
videoId: '{{ $video->getRouteKey() }}',
|
||||||
csrf: '{{ csrf_token() }}',
|
csrf: '{{ csrf_token() }}',
|
||||||
userId: {{ $currentUserId }},
|
userId: {{ $currentUserId }},
|
||||||
|
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
|
||||||
|
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
|
||||||
deleteId: null,
|
deleteId: null,
|
||||||
toastTimer: 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 ──────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
@@ -877,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
|||||||
const rt = e.target.closest('._comment-reply-trigger');
|
const rt = e.target.closest('._comment-reply-trigger');
|
||||||
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
|
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
|
||||||
|
|
||||||
|
// Replying to a reply — open the parent-level reply form and
|
||||||
|
// prefill it with the reply author's @handle so the thread stays
|
||||||
|
// readable even though the DB only stores one-level nesting.
|
||||||
|
const rtn = e.target.closest('._comment-reply-nested-trigger');
|
||||||
|
if (rtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const parentId = rtn.dataset.parentId;
|
||||||
|
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
|
||||||
|
toggleReplyForm(parentId);
|
||||||
|
const ta = document.getElementById('replyBody' + parentId);
|
||||||
|
if (ta && handle) {
|
||||||
|
const mention = '@' + handle + ' ';
|
||||||
|
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
|
autoResize(ta);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Edit trigger
|
// Edit trigger
|
||||||
const et = e.target.closest('._comment-edit-trigger');
|
const et = e.target.closest('._comment-edit-trigger');
|
||||||
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
||||||
@@ -904,12 +1165,148 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
|||||||
// Dislike btn
|
// Dislike btn
|
||||||
const db = e.target.closest('.ytc-dislike-btn');
|
const db = e.target.closest('.ytc-dislike-btn');
|
||||||
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
||||||
|
|
||||||
|
// Creator heart — owner-only, server rejects anyone else
|
||||||
|
const hb = e.target.closest('.ytc-heart-toggle');
|
||||||
|
if (hb) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = hb.dataset.commentId;
|
||||||
|
fetch('/comments/' + id + '/heart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.hearted !== undefined) {
|
||||||
|
hb.classList.toggle('active', d.hearted);
|
||||||
|
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
|
||||||
|
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
|
||||||
|
// Also toggle the visible heart badge in the comment meta.
|
||||||
|
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
|
||||||
|
let badge = meta?.querySelector('.ytc-heart-badge');
|
||||||
|
if (d.hearted && !badge && meta) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = 'ytc-heart-badge';
|
||||||
|
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
|
||||||
|
meta.appendChild(badge);
|
||||||
|
} else if (!d.hearted && badge) {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creator pin — owner-only, pins top-level comments to the list head
|
||||||
|
const pb = e.target.closest('.ytc-pin-toggle');
|
||||||
|
if (pb) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = pb.dataset.commentId;
|
||||||
|
fetch('/comments/' + id + '/pin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.pinned !== undefined) {
|
||||||
|
// Reload to re-sort — pin changes the order of top-level
|
||||||
|
// comments and the DOM move is cleaner server-side than
|
||||||
|
// trying to shuffle the live list.
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
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) {
|
function toggleReplyForm(commentId) {
|
||||||
const form = document.getElementById('replyForm' + commentId);
|
let form = document.getElementById('replyForm' + commentId);
|
||||||
if (!form) return;
|
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');
|
const isOpen = form.classList.contains('open');
|
||||||
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
|
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -970,16 +1367,26 @@ async function saveEdit(commentId) {
|
|||||||
|
|
||||||
// ── Post comment ──────────────────────────────────────
|
// ── Post comment ──────────────────────────────────────
|
||||||
async function postComment() {
|
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');
|
const ta = document.getElementById('ytcTextarea');
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
const text = ta.value.trim();
|
const text = ta.value.trim();
|
||||||
if (!text) { toast('Write something first', 'error'); return; }
|
if (!text) { toast('Write something first', 'error'); return; }
|
||||||
|
postComment._busy = true;
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Posting…';
|
submitBtn.textContent = 'Posting…';
|
||||||
try {
|
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', {
|
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
|
||||||
method: 'POST',
|
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 })
|
body: JSON.stringify({ body: text })
|
||||||
});
|
});
|
||||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||||
@@ -1000,6 +1407,7 @@ async function postComment() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
postComment._busy = false;
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Comment';
|
submitBtn.textContent = 'Comment';
|
||||||
}
|
}
|
||||||
@@ -1012,11 +1420,21 @@ async function postReply(vid, parentId) {
|
|||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text) { toast('Write something first', 'error'); return; }
|
if (!text) { toast('Write something first', 'error'); return; }
|
||||||
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
|
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 {
|
try {
|
||||||
|
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||||
const r = await fetch('/videos/' + vid + '/comments', {
|
const r = await fetch('/videos/' + vid + '/comments', {
|
||||||
method: 'POST',
|
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 })
|
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||||
});
|
});
|
||||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||||
@@ -1035,7 +1453,7 @@ async function postReply(vid, parentId) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
|
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +1551,12 @@ function buildCommentEl(c, isReply) {
|
|||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
|
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');
|
const div = document.createElement('div');
|
||||||
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
||||||
@@ -1143,10 +1567,12 @@ function buildCommentEl(c, isReply) {
|
|||||||
</a>
|
</a>
|
||||||
<div class="ytc-body-wrap">
|
<div class="ytc-body-wrap">
|
||||||
<div class="ytc-meta">
|
<div class="ytc-meta">
|
||||||
|
${flagEl}
|
||||||
<span class="ytc-author">${name}</span>
|
<span class="ytc-author">${name}</span>
|
||||||
<span class="ytc-time">${time}</span>
|
<span class="ytc-time">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
||||||
|
${translationPanel}
|
||||||
${editForm}
|
${editForm}
|
||||||
<div class="ytc-actions">
|
<div class="ytc-actions">
|
||||||
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
||||||
@@ -1157,6 +1583,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>
|
<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>
|
</button>
|
||||||
${replyBtn}
|
${replyBtn}
|
||||||
|
${translateBtn}
|
||||||
${moreMenu}
|
${moreMenu}
|
||||||
</div>
|
</div>
|
||||||
${replyForm}
|
${replyForm}
|
||||||
@@ -1226,6 +1653,32 @@ function toggleDislike(btn) {
|
|||||||
function initLikeStates() {} // server-rendered state is already in the HTML
|
function initLikeStates() {} // server-rendered state is already in the HTML
|
||||||
|
|
||||||
// ── Timestamp & @mention parsing ─────────────────────
|
// ── Timestamp & @mention parsing ─────────────────────
|
||||||
|
// ── Read-more clamp ──
|
||||||
|
// Flags long bodies (>4 lines once rendered) so a "Read more" button
|
||||||
|
// below the body expands them. Measured in computed line-height × 4.
|
||||||
|
function maybeClampBody(el) {
|
||||||
|
// Deferred one microtask so el.scrollHeight is correct even if the
|
||||||
|
// enclosing comment card just got inserted into the DOM.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
|
||||||
|
const limit = lh * 4;
|
||||||
|
if (el.scrollHeight <= limit + 1) return;
|
||||||
|
|
||||||
|
el.classList.add('ytc-clamp');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'ytc-read-more';
|
||||||
|
btn.textContent = 'Read more';
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const expanded = el.classList.toggle('ytc-clamp') === false;
|
||||||
|
btn.textContent = expanded ? 'Show less' : 'Read more';
|
||||||
|
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
el.insertAdjacentElement('afterend', btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function enhanceBody(el) {
|
function enhanceBody(el) {
|
||||||
if (!el || el.dataset._commentEnhanced === '1') return;
|
if (!el || el.dataset._commentEnhanced === '1') return;
|
||||||
let text = el.textContent || '';
|
let text = el.textContent || '';
|
||||||
@@ -1255,6 +1708,9 @@ function enhanceBody(el) {
|
|||||||
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
|
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Add the Read more affordance after enhancing (so links and timestamps
|
||||||
|
// are rendered first and the measured height is final).
|
||||||
|
maybeClampBody(el);
|
||||||
}
|
}
|
||||||
function enhanceAll() {
|
function enhanceAll() {
|
||||||
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
||||||
@@ -1309,16 +1765,39 @@ const listObserver = new MutationObserver(() => {
|
|||||||
const list = document.getElementById('ytcList');
|
const list = document.getElementById('ytcList');
|
||||||
if (list) listObserver.observe(list, { childList: true, subtree: true });
|
if (list) listObserver.observe(list, { childList: true, subtree: true });
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────
|
// ── Comment permalink (?lc=<id>) ─────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// Scroll to #comment-<id> and briefly highlight the row so a shared link
|
||||||
enhanceAll();
|
// lands on the exact message. The reply row is nested inside a .ytc-replies
|
||||||
initLikeStates();
|
// expander by default — we auto-open the parent thread first so the target
|
||||||
});
|
// is visible before we scroll.
|
||||||
if (document.readyState !== 'loading') {
|
function openCommentPermalink(id) {
|
||||||
enhanceAll();
|
if (!id) return;
|
||||||
initLikeStates();
|
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) ─────────────────
|
// ── Public API (for backward compat) ─────────────────
|
||||||
window._comment = {
|
window._comment = {
|
||||||
deleteComment: openDeleteModal,
|
deleteComment: openDeleteModal,
|
||||||
@@ -1327,3 +1806,22 @@ window._comment = {
|
|||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</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
|
||||||
@@ -205,12 +205,99 @@
|
|||||||
</div>{{-- /chrome-controls --}}
|
</div>{{-- /chrome-controls --}}
|
||||||
</div>{{-- /chrome-bottom --}}
|
</div>{{-- /chrome-bottom --}}
|
||||||
|
|
||||||
|
{{-- End screen: shown when the video reaches its 'ended' event. Carries
|
||||||
|
a thumbnail + title for the next recommended video and a radial
|
||||||
|
countdown ring; auto-advances when the ring completes, cancelled on
|
||||||
|
any click inside the player. --}}
|
||||||
|
<div class="ytp-end-screen" id="ytpEndScreen" aria-hidden="true">
|
||||||
|
<div class="ytp-end-inner">
|
||||||
|
<div class="ytp-end-label">Up next</div>
|
||||||
|
<a class="ytp-end-card" id="ytpEndCard" href="#">
|
||||||
|
<div class="ytp-end-thumb">
|
||||||
|
<img id="ytpEndThumb" src="" alt="" loading="lazy">
|
||||||
|
<div class="ytp-end-countdown">
|
||||||
|
<svg viewBox="0 0 36 36" aria-hidden="true">
|
||||||
|
<circle class="ytp-end-track" cx="18" cy="18" r="16"></circle>
|
||||||
|
<circle class="ytp-end-fill" cx="18" cy="18" r="16" id="ytpEndRing"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="ytp-end-timer" id="ytpEndTimer">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ytp-end-title" id="ytpEndTitle">Loading…</div>
|
||||||
|
</a>
|
||||||
|
<div class="ytp-end-actions">
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-cancel" id="ytpEndCancel">Cancel</button>
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-play" id="ytpEndPlay">Play now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>{{-- /ytp --}}
|
</div>{{-- /ytp --}}
|
||||||
</div>{{-- /ytp-wrap --}}
|
</div>{{-- /ytp-wrap --}}
|
||||||
|
|
||||||
|
|
||||||
@once
|
@once
|
||||||
<style>
|
<style>
|
||||||
|
/* ── End screen (autoplay countdown) ─────────────────────── */
|
||||||
|
.ytp-end-screen {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.78);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 20;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ytp-end-screen.visible { display: flex; }
|
||||||
|
.ytp-end-inner {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 16px; max-width: 560px; padding: 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.ytp-end-label {
|
||||||
|
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.ytp-end-card {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
text-decoration: none; color: inherit; cursor: pointer;
|
||||||
|
border-radius: 12px; padding: 12px; max-width: 420px;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-card:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
|
.ytp-end-thumb {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 16/9;
|
||||||
|
border-radius: 10px; overflow: hidden; background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.ytp-end-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ytp-end-countdown {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.ytp-end-countdown svg { width: 72px; height: 72px; transform: rotate(-90deg); }
|
||||||
|
.ytp-end-track { fill: none; stroke: rgba(255,255,255,.25); stroke-width: 3; }
|
||||||
|
.ytp-end-fill { fill: none; stroke: #fff; stroke-width: 3;
|
||||||
|
stroke-dasharray: 100.5; stroke-dashoffset: 100.5;
|
||||||
|
transition: stroke-dashoffset .1s linear; }
|
||||||
|
.ytp-end-timer {
|
||||||
|
position: absolute; font-size: 22px; font-weight: 700;
|
||||||
|
color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
.ytp-end-title {
|
||||||
|
font-size: 16px; font-weight: 500;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ytp-end-actions { display: flex; gap: 10px; }
|
||||||
|
.ytp-end-btn {
|
||||||
|
padding: 8px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
|
||||||
|
cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-btn:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.ytp-end-play { background: #ef4444; border-color: #ef4444; }
|
||||||
|
.ytp-end-play:hover { background: #dc2626; }
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
YTP PLAYER — YouTube-identical custom player
|
YTP PLAYER — YouTube-identical custom player
|
||||||
══════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════ */
|
||||||
@@ -1304,17 +1391,96 @@ function navigatePrev() {
|
|||||||
}
|
}
|
||||||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||||||
|
|
||||||
|
// ── End screen + autoplay countdown ─────────────────────
|
||||||
|
// Picks the first "Up Next" sidebar card as the next target and shows a
|
||||||
|
// 5-second countdown overlay. If the viewer doesn't cancel, we hand off to
|
||||||
|
// the existing _plOnVideoEnd / navigateNext flow.
|
||||||
|
const endScreen = document.getElementById('ytpEndScreen');
|
||||||
|
const endRing = document.getElementById('ytpEndRing');
|
||||||
|
const endTimer = document.getElementById('ytpEndTimer');
|
||||||
|
const endCard = document.getElementById('ytpEndCard');
|
||||||
|
const endThumb = document.getElementById('ytpEndThumb');
|
||||||
|
const endTitle = document.getElementById('ytpEndTitle');
|
||||||
|
const endCancel = document.getElementById('ytpEndCancel');
|
||||||
|
const endPlay = document.getElementById('ytpEndPlay');
|
||||||
|
let endInterval = null;
|
||||||
|
let endTargetUrl = null;
|
||||||
|
|
||||||
|
function closeEndScreen() {
|
||||||
|
if (endScreen) endScreen.classList.remove('visible');
|
||||||
|
if (endInterval) { clearInterval(endInterval); endInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findNextRecCard() {
|
||||||
|
// Prefer the first unwatched recommendation; fall back to the first card.
|
||||||
|
const list = document.querySelector('#recList');
|
||||||
|
if (!list) return null;
|
||||||
|
return list.querySelector('.sidebar-video-card[data-rec-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEndScreen() {
|
||||||
|
if (!endScreen) {
|
||||||
|
// No end-screen markup (e.g. shorts). Fall back to the original behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = _findNextRecCard();
|
||||||
|
if (!card) {
|
||||||
|
// Nothing to count down to — restore the old no-UI behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endTargetUrl = card.dataset.recUrl;
|
||||||
|
endCard.href = endTargetUrl;
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
const title = card.querySelector('.sidebar-title');
|
||||||
|
if (img && endThumb) endThumb.src = img.src;
|
||||||
|
if (title && endTitle) endTitle.textContent = title.textContent.trim();
|
||||||
|
|
||||||
|
endScreen.classList.add('visible');
|
||||||
|
|
||||||
|
let left = 5;
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = '100.5';
|
||||||
|
const start = Date.now();
|
||||||
|
const total = 5000;
|
||||||
|
endInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const remaining = Math.max(0, total - elapsed);
|
||||||
|
left = Math.ceil(remaining / 1000);
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = String(100.5 * (remaining / total));
|
||||||
|
if (remaining <= 0) {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
endCancel?.addEventListener('click', closeEndScreen);
|
||||||
|
endPlay?.addEventListener('click', () => {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
});
|
||||||
|
|
||||||
video.addEventListener('ended', () => {
|
video.addEventListener('ended', () => {
|
||||||
updatePlayIcon();
|
updatePlayIcon();
|
||||||
largePlay.classList.add('visible');
|
largePlay.classList.add('visible');
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
if (window._plOnVideoEnd) {
|
if (isLooping) { navigateNext(); return; }
|
||||||
window._plOnVideoEnd();
|
if (autoplayOn || window._plOnVideoEnd) {
|
||||||
} else if (autoplayOn || isLooping) {
|
openEndScreen();
|
||||||
navigateNext();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Any interaction inside the player cancels the countdown so the viewer
|
||||||
|
// can scrub back, replay, or just stop.
|
||||||
|
video.addEventListener('play', closeEndScreen);
|
||||||
|
video.addEventListener('seeking', closeEndScreen);
|
||||||
video.addEventListener('volumechange', updateVolumeIcon);
|
video.addEventListener('volumechange', updateVolumeIcon);
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────
|
// ── Keyboard shortcuts ────────────────────────────────
|
||||||
@@ -1339,10 +1505,39 @@ document.addEventListener('keydown', e => {
|
|||||||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||||||
case 'i': case 'I':
|
case 'i': case 'I':
|
||||||
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
||||||
|
case 'c': case 'C':
|
||||||
|
// Captions / lyrics toggle — click the lyrics button when the
|
||||||
|
// page has one (music type). On a video that has no lyrics
|
||||||
|
// the key is a no-op; we don't throw up a toast because the
|
||||||
|
// viewer may just be hitting letters next to K / L / M.
|
||||||
|
{
|
||||||
|
const lyr = document.getElementById('ytpLyricsBtn');
|
||||||
|
if (lyr && lyr.offsetParent !== null) { e.preventDefault(); lyr.click(); showControls(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'j': case 'J':
|
case 'j': case 'J':
|
||||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||||
case 'l': case 'L':
|
case 'l': case 'L':
|
||||||
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
||||||
|
case '>':
|
||||||
|
case '.':
|
||||||
|
// YouTube parity: shift+. (which produces ">") steps playback
|
||||||
|
// rate up in 0.25 jumps. "." alone also works for keyboards
|
||||||
|
// where ">" needs a modifier the browser doesn't surface.
|
||||||
|
if (e.shiftKey || e.key === '>') {
|
||||||
|
e.preventDefault();
|
||||||
|
video.playbackRate = Math.min(2, Math.round((video.playbackRate + 0.25) * 100) / 100);
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
case ',':
|
||||||
|
if (e.shiftKey || e.key === '<') {
|
||||||
|
e.preventDefault();
|
||||||
|
video.playbackRate = Math.max(0.25, Math.round((video.playbackRate - 0.25) * 100) / 100);
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
|
|||||||
@@ -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>
|
<script src="{{ asset('fp.js') }}" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
<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 -->
|
<!-- Header -->
|
||||||
@include('layouts.partials.header')
|
@include('layouts.partials.header')
|
||||||
|
|
||||||
@@ -1237,10 +1251,11 @@
|
|||||||
|
|
||||||
<!-- Mobile Search Overlay -->
|
<!-- Mobile Search Overlay -->
|
||||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
|
||||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
<label for="mobile-search-input" class="visually-hidden">Search</label>
|
||||||
<button type="submit" class="mobile-search-submit">
|
<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">
|
||||||
<i class="bi bi-search"></i>
|
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
|
||||||
|
<i class="bi bi-search" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -1697,11 +1712,24 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function loadNotifications() {
|
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' } })
|
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); })
|
.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 () {
|
window.toggleNotifPanel = function () {
|
||||||
var btn = document.getElementById('notifBtn');
|
var btn = document.getElementById('notifBtn');
|
||||||
|
|||||||
@@ -178,22 +178,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}}
|
{{-- Secondary tracks in this modal are cloned from the SHARED
|
||||||
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true">
|
#um-track-form-template defined in layouts.partials.upload-modal. One
|
||||||
<x-track-editor-form
|
template → identical UX in both the create (upload) modal and this modal. --}}
|
||||||
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>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
#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) {
|
function editHandleSlides(fileList, tid) {
|
||||||
tid = tid || 't1';
|
tid = tid || 't1';
|
||||||
_editSlidesCropStart(fileList, tid, _editRenderSlides);
|
_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 ──
|
// ── 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; }
|
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
|
||||||
const f = _editSlidesCropQueue.shift();
|
const f = _editSlidesCropQueue.shift();
|
||||||
if (typeof window.tcPreload_slides_edit === 'function') {
|
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();
|
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) {
|
function editHandleSlidesForTrack(tid, fileList) {
|
||||||
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
||||||
|
const inp = document.getElementById('edit-slides-input-' + tid);
|
||||||
|
if (inp) inp.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function editClearSlidesForTrack(e, tid) {
|
function editClearSlidesForTrack(e, tid) {
|
||||||
@@ -885,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
|
|||||||
// ── Track popup ───────────────────────────────────────────────────────────────
|
// ── Track popup ───────────────────────────────────────────────────────────────
|
||||||
function editOpenTrackPopup(trackId) {
|
function editOpenTrackPopup(trackId) {
|
||||||
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
|
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';
|
if (form) form.style.display = 'block';
|
||||||
const popup = document.getElementById('edit-track-popup');
|
const popup = document.getElementById('edit-track-popup');
|
||||||
popup.style.opacity = '0';
|
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) {
|
function editUpdateTrackCard(trackId) {
|
||||||
let langCode = '', title = '';
|
let langCode = '', title = '';
|
||||||
if (trackId === 't1') {
|
if (trackId === 't1') {
|
||||||
@@ -910,9 +911,8 @@ function editUpdateTrackCard(trackId) {
|
|||||||
langCode = li ? li.value : '';
|
langCode = li ? li.value : '';
|
||||||
title = ti ? ti.value.trim() : '';
|
title = ti ? ti.value.trim() : '';
|
||||||
} else {
|
} else {
|
||||||
const n = trackId.replace('e','');
|
const li = document.getElementById('track-lang-' + trackId);
|
||||||
const li = document.getElementById('csd_v_e' + n);
|
const ti = document.getElementById('track-title-' + trackId);
|
||||||
const ti = document.getElementById('edit-e' + n + '-title');
|
|
||||||
langCode = li ? li.value : '';
|
langCode = li ? li.value : '';
|
||||||
title = ti ? ti.value.trim() : '';
|
title = ti ? ti.value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -932,24 +932,26 @@ function editUpdateTrackCard(trackId) {
|
|||||||
// ── Extra track management ────────────────────────────────────────────────────
|
// ── Extra track management ────────────────────────────────────────────────────
|
||||||
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
|
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) {
|
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
|
||||||
const tpl = document.getElementById('edit-extra-track-tpl');
|
const frag = window.cloneTrackFormTemplate({
|
||||||
if (!tpl) return null;
|
key: prefix, langName, titleName, descName, audioName,
|
||||||
const html = tpl.innerHTML
|
});
|
||||||
.replaceAll('__TPL__', prefix)
|
if (!frag) return null;
|
||||||
.replaceAll('__LANGNAME__', langName)
|
const host = document.getElementById('edit-tf-extra');
|
||||||
.replaceAll('__TITLENAME__', titleName)
|
host.appendChild(frag);
|
||||||
.replaceAll('__DESCNAME__', descName)
|
// _slidesData is the shared state used by the shared slides-zone handlers.
|
||||||
.replaceAll('__AUDIONAME__', audioName);
|
// Mirror the handle under _editSlidesData so the edit submit code stays
|
||||||
const tmp = document.createElement('div');
|
// oblivious to the migration (one array, two aliases).
|
||||||
tmp.innerHTML = html;
|
window._slidesData = window._slidesData || {};
|
||||||
const formEl = tmp.querySelector('.um-track-form');
|
_slidesData[prefix] = [];
|
||||||
if (!formEl) return null;
|
_editSlidesData[prefix] = _slidesData[prefix];
|
||||||
formEl.style.display = 'none';
|
window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
|
||||||
document.getElementById('edit-tf-extra').appendChild(formEl);
|
return document.getElementById('um-tf-' + prefix);
|
||||||
_editSlidesData[prefix] = [];
|
|
||||||
if (window.CSD) new CSD('csd_' + prefix);
|
|
||||||
return formEl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _editTrackCard(n, trackId, isExisting) {
|
function _editTrackCard(n, trackId, isExisting) {
|
||||||
@@ -990,34 +992,47 @@ function _editAddExistingTrack(track) {
|
|||||||
'track_description_updates[' + track.id + ']',
|
'track_description_updates[' + track.id + ']',
|
||||||
'track_file_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 || '';
|
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 (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
|
||||||
if (track.language && window.CSD) {
|
if (track.language && window.CSD) {
|
||||||
const hi = document.getElementById('csd_v_' + pfx);
|
const hi = document.getElementById('track-lang-' + pfx);
|
||||||
const wrap = document.getElementById('csd_' + pfx);
|
const wrap = document.getElementById('lsd_track_' + pfx);
|
||||||
if (hi) hi.value = track.language;
|
if (hi) hi.value = track.language;
|
||||||
if (wrap) _editSetLangCsd(wrap, track.language);
|
if (wrap) _editSetLangCsd(wrap, track.language);
|
||||||
}
|
}
|
||||||
const hi = document.getElementById('csd_v_' + pfx);
|
|
||||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
|
||||||
editUpdateTrackCard(pfx);
|
editUpdateTrackCard(pfx);
|
||||||
_editUpdateTrackPositions();
|
_editUpdateTrackPositions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editDeleteTrack(trackId, n) {
|
function _editRemoveTrackForms(n) {
|
||||||
_editDeleteTrackIds.push(trackId);
|
const pfx = 'e' + n;
|
||||||
document.getElementById('edit-tc-e'+n)?.remove();
|
document.getElementById('edit-tc-' + pfx)?.remove();
|
||||||
document.getElementById('edit-tf-e'+n)?.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');
|
const popup = document.getElementById('edit-track-popup');
|
||||||
if (popup.style.display !== 'none') {
|
if (popup.style.display !== 'none') {
|
||||||
const vis = popup.querySelector('.um-track-form:not([style*="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();
|
_editUpdateTrackPositions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editRemoveNewTrack(n) {
|
||||||
|
_editRemoveTrackForms(n);
|
||||||
|
}
|
||||||
|
|
||||||
function editAddExtraTrack() {
|
function editAddExtraTrack() {
|
||||||
const n = ++_editExtraCount;
|
const n = ++_editExtraCount;
|
||||||
const pfx = 'e' + n;
|
const pfx = 'e' + n;
|
||||||
@@ -1029,23 +1044,10 @@ function editAddExtraTrack() {
|
|||||||
'extra_track_descriptions[]',
|
'extra_track_descriptions[]',
|
||||||
'extra_track_files[]'
|
'extra_track_files[]'
|
||||||
);
|
);
|
||||||
const hi = document.getElementById('csd_v_' + pfx);
|
|
||||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
|
||||||
_editUpdateTrackPositions();
|
_editUpdateTrackPositions();
|
||||||
editOpenTrackPopup(pfx);
|
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
|
// Wire primary language → update card
|
||||||
;(function() {
|
;(function() {
|
||||||
const li = document.getElementById('edit_primary_language');
|
const li = document.getElementById('edit_primary_language');
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="yt-header">
|
<header class="yt-header">
|
||||||
<div class="yt-header-left">
|
<div class="yt-header-left">
|
||||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
|
||||||
<i class="bi bi-list fs-5"></i>
|
<i class="bi bi-list fs-5" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('home') }}" class="yt-logo">
|
<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/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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-header-center d-none d-md-flex">
|
<div class="yt-header-center d-none d-md-flex">
|
||||||
<div class="yt-search">
|
<div class="yt-search">
|
||||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
|
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
|
||||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
<label for="header-search-input" class="visually-hidden">Search</label>
|
||||||
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-header-right">
|
<div class="yt-header-right">
|
||||||
<!-- Mobile search -->
|
<!-- Mobile search -->
|
||||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
|
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-notif-list" id="notifList">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,9 +60,10 @@
|
|||||||
<!-- User dropdown -->
|
<!-- User dropdown -->
|
||||||
<div class="yt-user-dropdown" id="headerUserDropdown">
|
<div class="yt-user-dropdown" id="headerUserDropdown">
|
||||||
<button class="yt-icon-btn" id="headerUserBtn"
|
<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 }}"
|
<img src="{{ Auth::user()->avatar_url }}"
|
||||||
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
|
class="yt-user-avatar" alt="">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="yt-user-panel" id="headerUserPanel" role="menu">
|
<div class="yt-user-panel" id="headerUserPanel" role="menu">
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<span>Trending</span>
|
<span>Trending</span>
|
||||||
</a>
|
</a>
|
||||||
@auth
|
@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>
|
<i class="bi bi-collection-play-fill"></i>
|
||||||
<span>Subscriptions</span>
|
<span>Subscriptions</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -56,6 +57,10 @@
|
|||||||
<i class="bi bi-hand-thumbs-up-fill"></i>
|
<i class="bi bi-hand-thumbs-up-fill"></i>
|
||||||
<span>Liked Videos</span>
|
<span>Liked Videos</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-sliders"></i>
|
||||||
|
<span>Studio</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
|||||||
@@ -338,9 +338,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>{{-- /um-tf-t1 --}}
|
</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>
|
<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>{{-- /um-tp-body --}}
|
||||||
<div class="um-tp-footer">
|
<div class="um-tp-footer">
|
||||||
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
|
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
|
||||||
@@ -1372,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
|
|||||||
|
|
||||||
function handleSlidesForTrack(tid, fileList) {
|
function handleSlidesForTrack(tid, fileList) {
|
||||||
_slidesCropStart(fileList, tid);
|
_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 ──
|
// ── 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; }
|
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
|
||||||
const f = _slidesCropQueue.shift();
|
const f = _slidesCropQueue.shift();
|
||||||
if (typeof window.tcPreload_slides_upload === 'function') {
|
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();
|
window.openCropperModal_slides_upload();
|
||||||
|
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1400,7 +1430,13 @@ function uploadSlidesCropDone(file) {
|
|||||||
if (tid && file) {
|
if (tid && file) {
|
||||||
if (!_slidesData[tid]) _slidesData[tid] = [];
|
if (!_slidesData[tid]) _slidesData[tid] = [];
|
||||||
if (_slidesData[tid].length < 10) {
|
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);
|
renderSlidesStrip(tid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1422,7 +1458,10 @@ function renderSlidesStrip(tid) {
|
|||||||
if (zone) zone.style.borderColor = '';
|
if (zone) zone.style.borderColor = '';
|
||||||
return;
|
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');
|
const wrap = document.createElement('div');
|
||||||
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
||||||
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
|
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
|
||||||
@@ -1441,10 +1480,15 @@ function renderSlidesStrip(tid) {
|
|||||||
const rb = document.createElement('button');
|
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.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.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');
|
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='';
|
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);
|
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';
|
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) {
|
function clearSlidesForTrack(e, tid) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
|
||||||
_slidesData[tid] = [];
|
_slidesData[tid] = [];
|
||||||
const input = document.getElementById('slides-input-' + 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); }
|
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 : '';
|
langCode = li ? li.value : '';
|
||||||
title = ti ? ti.value.trim() : '';
|
title = ti ? ti.value.trim() : '';
|
||||||
} else {
|
} else {
|
||||||
const n = trackId.replace('e', '');
|
const li = document.getElementById('track-lang-' + trackId);
|
||||||
const uid = 'ltme_' + n;
|
const ti = document.getElementById('track-title-' + trackId);
|
||||||
const li = document.getElementById('csd_v_' + uid);
|
|
||||||
const ti = document.getElementById('um-tf-title-' + trackId);
|
|
||||||
langCode = li ? li.value : '';
|
langCode = li ? li.value : '';
|
||||||
title = ti ? ti.value.trim() : '';
|
title = ti ? ti.value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -1512,49 +1555,111 @@ function updateTrackCard(trackId) {
|
|||||||
if (card) card.style.display = hasContent ? '' : 'none';
|
if (card) card.style.display = hasContent ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagEl = document.getElementById('um-tc-flag-' + trackId);
|
// Update WHICHEVER card id convention exists — the create modal uses
|
||||||
const titleEl = document.getElementById('um-tc-title-' + trackId);
|
// `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
|
||||||
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
// both keeps this helper a single source of truth for any shared template.
|
||||||
if (titleEl) {
|
const flagEls = [
|
||||||
if (lang && title) titleEl.textContent = title + ' — ' + lang.label;
|
document.getElementById('um-tc-flag-' + trackId),
|
||||||
else if (lang) titleEl.textContent = lang.label;
|
document.getElementById('edit-tc-flag-' + trackId),
|
||||||
else if (title) titleEl.textContent = title;
|
];
|
||||||
else titleEl.textContent = '';
|
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 ────────────────────────────────────
|
// ── Extra track management ────────────────────────────────────
|
||||||
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
|
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||||
|
|
||||||
function _ltBuildOptsHtml() {
|
// Audio picker handler shared by every track form (primary + extras) cloned
|
||||||
return LANG_OPTIONS_MODAL.map(o =>
|
// from the <template id="um-track-form-template"> blade partial.
|
||||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
function handleTrackAudio(key, input) {
|
||||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
const file = input.files && input.files[0];
|
||||||
<span class="csd-opt-main">${o.label}</span>
|
const fnameEl = document.getElementById('track-audio-fname-' + key);
|
||||||
<span class="csd-opt-sub">${o.native}</span>
|
const box = document.getElementById('track-audio-box-' + key);
|
||||||
</li>`
|
if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
|
||||||
).join('');
|
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() {
|
function addExtraTrackModal() {
|
||||||
const n = ++_umExtraCount;
|
const n = ++_umExtraCount;
|
||||||
const trackNum = n + 1;
|
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');
|
const card = document.createElement('div');
|
||||||
card.className = 'um-track-card';
|
card.className = 'um-track-card';
|
||||||
card.id = 'um-tc-e' + n;
|
card.id = 'um-tc-' + key;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="um-tc-body">
|
<div class="um-tc-body">
|
||||||
<div class="um-tc-left">
|
<div class="um-tc-left">
|
||||||
<div class="um-tc-num">${trackNum}</div>
|
<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">
|
<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>
|
</div>
|
||||||
<div class="um-tc-right">
|
<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>
|
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
|
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
|
||||||
@@ -1564,100 +1669,24 @@ function addExtraTrackModal() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById('um-tc-extra').appendChild(card);
|
document.getElementById('um-tc-extra').appendChild(card);
|
||||||
|
|
||||||
const formEl = document.createElement('div');
|
// Clone the SHARED track-form template and substitute placeholders. The same
|
||||||
formEl.className = 'um-track-form';
|
// template is cloned by the edit modal (editAddExtraTrack / existing-track
|
||||||
formEl.id = 'um-tf-e' + n;
|
// loader), so a change here automatically shows up in both flows.
|
||||||
formEl.style.display = 'none';
|
const frag = window.cloneTrackFormTemplate({
|
||||||
formEl.innerHTML = `
|
key: key,
|
||||||
<div class="um-tf-row2" style="margin-bottom:14px;">
|
langName: 'extra_track_languages[]',
|
||||||
<div>
|
titleName: 'extra_track_titles[]',
|
||||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label>
|
descName: 'extra_track_descriptions[]',
|
||||||
<div class="csd-wrap" id="${uid}">
|
audioName: 'extra_track_files[]',
|
||||||
<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>
|
if (!frag) return;
|
||||||
<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>`;
|
|
||||||
|
|
||||||
document.getElementById('um-tf-extra').appendChild(formEl);
|
const host = document.getElementById('um-tf-extra');
|
||||||
_slidesData['e' + n] = [];
|
host.appendChild(frag);
|
||||||
|
_slidesData[key] = [];
|
||||||
|
|
||||||
if (window.CSD) new CSD(uid);
|
window.initTrackFormNode(key);
|
||||||
|
openTrackPopup(key);
|
||||||
const hiddenInput = document.getElementById('csd_v_' + uid);
|
|
||||||
if (hiddenInput) {
|
|
||||||
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
|
|
||||||
}
|
|
||||||
|
|
||||||
openTrackPopup('e' + n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeExtraTrackModal(n) {
|
function removeExtraTrackModal(n) {
|
||||||
@@ -1686,13 +1715,15 @@ function removeExtraTrackModal(n) {
|
|||||||
|
|
||||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
||||||
async function _chunkedUploadVideo(file, onProgress) {
|
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 total = Math.max(1, Math.ceil(file.size / CHUNK));
|
||||||
const uploadId = (window.crypto && crypto.randomUUID)
|
const uploadId = (window.crypto && crypto.randomUUID)
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
: (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 start = i * CHUNK;
|
||||||
const end = Math.min(start + CHUNK, file.size);
|
const end = Math.min(start + CHUNK, file.size);
|
||||||
const blob = file.slice(start, end);
|
const blob = file.slice(start, end);
|
||||||
@@ -1700,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('upload_id', uploadId);
|
fd.append('upload_id', uploadId);
|
||||||
fd.append('chunk_index', i);
|
fd.append('chunk_index', i);
|
||||||
|
fd.append('chunk_offset', start);
|
||||||
fd.append('total_chunks', total);
|
fd.append('total_chunks', total);
|
||||||
fd.append('filename', file.name);
|
fd.append('filename', file.name);
|
||||||
fd.append('chunk', blob, 'chunk');
|
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 (_) {}
|
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
|
||||||
throw new Error(msg);
|
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 };
|
return { uploadId: uploadId, filename: file.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1753,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData(this);
|
const formData = new FormData(this);
|
||||||
|
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
|
||||||
if (_isMusicMode) {
|
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
|
// Walk extra-track form sections in DOM order so the slide index lines up
|
||||||
// with the positional `extra_track_files[]` the form already submits.
|
// with the positional `extra_track_files[]` the form already submits.
|
||||||
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
|
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;
|
if (!m) return;
|
||||||
var tid = m[1];
|
var tid = m[1];
|
||||||
var files = _slidesData[tid] || [];
|
var files = _slidesData[tid] || [];
|
||||||
files.forEach(function (f) {
|
files.forEach(function (s) {
|
||||||
formData.append('extra_track_slides[' + domIdx + '][]', f);
|
formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Go Live — '.$stream->title)
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
.lv-wrap { max-width: 1100px; margin: 0 auto; padding: 16px; }
|
||||||
|
|
||||||
|
/* The preview owns the screen on a phone. Everything else floats on it. */
|
||||||
|
.lv-stage {
|
||||||
|
position: relative; background: #000; border-radius: 16px; overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.lv-stage video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||||
|
|
||||||
|
/* Mirror only the selfie camera — a mirrored rear camera looks broken. */
|
||||||
|
.lv-stage video.mirrored { transform: scaleX(-1); }
|
||||||
|
|
||||||
|
.lv-badge {
|
||||||
|
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
|
||||||
|
background: rgba(0,0,0,.6); color: #fff; backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.lv-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; }
|
||||||
|
.lv-badge.is-live { background: rgba(239,68,68,.92); }
|
||||||
|
.lv-badge.is-live .dot { background: #fff; animation: lvPulse 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes lvPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.75); } }
|
||||||
|
|
||||||
|
.lv-stats {
|
||||||
|
position: absolute; top: 12px; right: 12px; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
padding: 8px 12px; border-radius: 12px; background: rgba(0,0,0,.55); backdrop-filter: blur(8px);
|
||||||
|
color: #e5e7eb; font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; min-width: 120px;
|
||||||
|
}
|
||||||
|
.lv-stats b { color: #fff; font-weight: 600; }
|
||||||
|
.lv-stats .warn { color: #fbbf24; }
|
||||||
|
.lv-stats .bad { color: #f87171; }
|
||||||
|
|
||||||
|
.lv-controls {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; padding: 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
||||||
|
}
|
||||||
|
.lv-btn {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
|
||||||
|
background: rgba(255,255,255,.12); color: #fff; font-size: 20px; cursor: pointer;
|
||||||
|
display: grid; place-items: center; backdrop-filter: blur(8px); transition: transform .12s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.lv-btn:hover { background: rgba(255,255,255,.22); }
|
||||||
|
.lv-btn:active { transform: scale(.92); }
|
||||||
|
.lv-btn.off { background: rgba(239,68,68,.85); border-color: transparent; }
|
||||||
|
.lv-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.lv-golive {
|
||||||
|
min-width: 150px; height: 52px; padding: 0 26px; border-radius: 999px; border: 0;
|
||||||
|
background: #ef4444; color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; transition: transform .12s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.lv-golive:hover { background: #dc2626; }
|
||||||
|
.lv-golive:active { transform: scale(.97); }
|
||||||
|
.lv-golive:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
.lv-golive.is-live { background: #111827; border: 1px solid rgba(255,255,255,.25); }
|
||||||
|
|
||||||
|
.lv-meta { margin-top: 18px; display: grid; gap: 14px; grid-template-columns: 1fr; }
|
||||||
|
@media (min-width: 860px) { .lv-meta { grid-template-columns: 1.4fr 1fr; } }
|
||||||
|
.lv-card { background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); border-radius: 14px; padding: 16px; }
|
||||||
|
.lv-card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .6; margin: 0 0 10px; }
|
||||||
|
.lv-share { display: flex; gap: 8px; }
|
||||||
|
.lv-share input {
|
||||||
|
flex: 1; min-width: 0; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12);
|
||||||
|
border-radius: 10px; padding: 10px 12px; color: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
.lv-note { font-size: 12px; opacity: .6; line-height: 1.6; margin-top: 10px; }
|
||||||
|
|
||||||
|
.lv-msg { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 14px; display: none; }
|
||||||
|
.lv-msg.err { display: block; background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.35); color: #fca5a5; }
|
||||||
|
.lv-msg.ok { display: block; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.35); color: #86efac; }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="lv-wrap">
|
||||||
|
|
||||||
|
<div class="lv-stage">
|
||||||
|
<video id="lvPreview" autoplay playsinline muted></video>
|
||||||
|
|
||||||
|
<div class="lv-badge" id="lvBadge"><span class="dot"></span><span id="lvBadgeText">READY</span></div>
|
||||||
|
|
||||||
|
<div class="lv-stats" id="lvStats" style="display:none;">
|
||||||
|
<div><b id="lvRes">—</b> · <b id="lvFps">—</b> fps</div>
|
||||||
|
<div><b id="lvBitrate">—</b></div>
|
||||||
|
<div id="lvHealth">—</div>
|
||||||
|
<div><b id="lvTimer">00:00</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lv-controls">
|
||||||
|
<button class="lv-btn" id="lvMic" title="Mute microphone"><i class="bi bi-mic-fill"></i></button>
|
||||||
|
<button class="lv-golive" id="lvGoLive"><i class="bi bi-broadcast"></i><span id="lvGoLiveText">Go Live</span></button>
|
||||||
|
<button class="lv-btn" id="lvFlip" title="Switch camera"><i class="bi bi-arrow-repeat"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lv-msg" id="lvMsg"></div>
|
||||||
|
|
||||||
|
<div class="lv-meta">
|
||||||
|
<div class="lv-card">
|
||||||
|
<h3>{{ $stream->title }}</h3>
|
||||||
|
@if ($stream->description)
|
||||||
|
<p style="margin:0 0 10px;font-size:14px;opacity:.8;">{{ $stream->description }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="lv-share">
|
||||||
|
<input type="text" id="lvWatchUrl" value="{{ $watchUrl }}" readonly onclick="this.select()">
|
||||||
|
<button class="action-btn" type="button" id="lvCopy"><i class="bi bi-clipboard"></i> <span>Copy</span></button>
|
||||||
|
</div>
|
||||||
|
<p class="lv-note">
|
||||||
|
Anyone with this link can watch while you are live.
|
||||||
|
{{ $stream->visibility === 'public' ? 'This stream is also listed on the Live page.' : 'This stream is unlisted — only people with the link can find it.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lv-card">
|
||||||
|
<h3>Quality</h3>
|
||||||
|
<p class="lv-note" style="margin-top:0;">
|
||||||
|
Targeting {{ config('live.video_width') }}×{{ config('live.video_height') }} at {{ config('live.video_fps') }} fps.
|
||||||
|
If your upload speed drops, the picture softens instead of freezing — that is the connection adapting, not a fault.
|
||||||
|
Wi-Fi or 5G will hold 1080p most reliably.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var CFG = {
|
||||||
|
whipUrl: @json($whipUrl),
|
||||||
|
tokenUrl: @json(route('live.token', $stream)),
|
||||||
|
stopUrl: @json(route('live.stop', $stream)),
|
||||||
|
csrf: @json(csrf_token()),
|
||||||
|
iceServers: @json($iceServers),
|
||||||
|
width: {{ (int) config('live.video_width') }},
|
||||||
|
height: {{ (int) config('live.video_height') }},
|
||||||
|
fps: {{ (int) config('live.video_fps') }},
|
||||||
|
maxBitrate: {{ (int) config('live.max_bitrate') }}
|
||||||
|
};
|
||||||
|
|
||||||
|
var el = function (id) { return document.getElementById(id); };
|
||||||
|
var preview = el('lvPreview'), badge = el('lvBadge'), badgeText = el('lvBadgeText');
|
||||||
|
var goLive = el('lvGoLive'), goLiveText = el('lvGoLiveText');
|
||||||
|
var msg = el('lvMsg'), stats = el('lvStats');
|
||||||
|
|
||||||
|
var stream = null, pc = null, statsTimer = null, clockTimer = null;
|
||||||
|
var facing = 'environment', live = false, startedAt = 0;
|
||||||
|
|
||||||
|
function say(text, kind) {
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.className = 'lv-msg ' + (kind || 'err');
|
||||||
|
}
|
||||||
|
function clearSay() { msg.className = 'lv-msg'; msg.textContent = ''; }
|
||||||
|
|
||||||
|
/* ---------------- Camera ---------------- */
|
||||||
|
|
||||||
|
// Ask for 1080p as an IDEAL, never `exact`: a phone whose camera cannot do
|
||||||
|
// it should hand back its best effort, not refuse to open at all.
|
||||||
|
function constraints() {
|
||||||
|
return {
|
||||||
|
video: {
|
||||||
|
width: { ideal: CFG.width },
|
||||||
|
height: { ideal: CFG.height },
|
||||||
|
frameRate: { ideal: CFG.fps },
|
||||||
|
facingMode: { ideal: facing }
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCamera() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
say('This browser cannot open a camera. On iPhone use Safari; on Android use Chrome.');
|
||||||
|
goLive.disabled = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserMedia only exists in a secure context. Saying so plainly beats
|
||||||
|
// a bare "Permission denied" the user cannot act on.
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
say('The camera needs a secure (HTTPS) connection. Open this page over https.');
|
||||||
|
goLive.disabled = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); }
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(constraints());
|
||||||
|
} catch (e) {
|
||||||
|
if (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError')) {
|
||||||
|
say('Camera and microphone access was blocked. Allow it in your browser settings, then reload.');
|
||||||
|
} else if (e && e.name === 'NotFoundError') {
|
||||||
|
say('No camera was found on this device.');
|
||||||
|
} else if (e && e.name === 'NotReadableError') {
|
||||||
|
say('The camera is already in use by another app. Close it and try again.');
|
||||||
|
} else {
|
||||||
|
say('Could not start the camera: ' + (e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
goLive.disabled = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.srcObject = stream;
|
||||||
|
preview.classList.toggle('mirrored', facing === 'user');
|
||||||
|
goLive.disabled = false;
|
||||||
|
clearSay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Publish (WHIP) ---------------- */
|
||||||
|
|
||||||
|
async function startBroadcast() {
|
||||||
|
goLive.disabled = true;
|
||||||
|
goLiveText.textContent = 'Starting…';
|
||||||
|
clearSay();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The publish credential is minted now, not at page load, so its
|
||||||
|
// short life starts when the user actually goes live.
|
||||||
|
var tokenRes = await fetch(CFG.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!tokenRes.ok) { throw new Error('Could not get permission to publish (' + tokenRes.status + ').'); }
|
||||||
|
var auth = await tokenRes.json();
|
||||||
|
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
iceServers: auth.ice_servers && auth.ice_servers.length ? auth.ice_servers : CFG.iceServers,
|
||||||
|
bundlePolicy: 'max-bundle'
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', function () {
|
||||||
|
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||||
|
say('The connection to the server dropped. Check your signal and go live again.');
|
||||||
|
stopBroadcast(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.getTracks().forEach(function (track) {
|
||||||
|
// maintain-resolution keeps 1920x1080 and sheds frame rate when
|
||||||
|
// the uplink tightens. For a match or a performance, a sharp
|
||||||
|
// 20fps reads better than a soft, blocky 30.
|
||||||
|
if (track.kind === 'video') { track.contentHint = 'detail'; }
|
||||||
|
pc.addTrack(track, stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
var videoSender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
|
||||||
|
if (videoSender) {
|
||||||
|
var params = videoSender.getParameters();
|
||||||
|
params.encodings = params.encodings && params.encodings.length ? params.encodings : [{}];
|
||||||
|
params.encodings[0].maxBitrate = CFG.maxBitrate;
|
||||||
|
params.encodings[0].maxFramerate = CFG.fps;
|
||||||
|
params.degradationPreference = 'maintain-resolution';
|
||||||
|
try { await videoSender.setParameters(params); } catch (e) { /* older browsers ignore this */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
await iceGatheringComplete(pc);
|
||||||
|
|
||||||
|
var res = await fetch(auth.whip_url || CFG.whipUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/sdp',
|
||||||
|
// Basic auth keeps the credential out of the URL, and so
|
||||||
|
// out of proxy and access logs.
|
||||||
|
'Authorization': 'Basic ' + btoa('publisher:' + auth.token)
|
||||||
|
},
|
||||||
|
body: pc.localDescription.sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) { throw new Error('The server refused the broadcast. Reload the page and try again.'); }
|
||||||
|
if (!res.ok) { throw new Error('The server rejected the stream (' + res.status + ').'); }
|
||||||
|
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
||||||
|
|
||||||
|
live = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
badge.classList.add('is-live');
|
||||||
|
badgeText.textContent = 'LIVE';
|
||||||
|
goLive.classList.add('is-live');
|
||||||
|
goLiveText.textContent = 'End Stream';
|
||||||
|
goLive.disabled = false;
|
||||||
|
stats.style.display = 'flex';
|
||||||
|
say('You are live. Share the link below.', 'ok');
|
||||||
|
|
||||||
|
statsTimer = setInterval(sampleStats, 1000);
|
||||||
|
clockTimer = setInterval(tickClock, 1000);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
say(e && e.message ? e.message : String(e));
|
||||||
|
await stopBroadcast(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for ICE to finish rather than trickling: WHIP is a single
|
||||||
|
// offer/answer exchange, so the offer must already carry the candidates.
|
||||||
|
function iceGatheringComplete(pc) {
|
||||||
|
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var done = false;
|
||||||
|
function finish() { if (!done) { done = true; resolve(); } }
|
||||||
|
pc.addEventListener('icegatheringstatechange', function () {
|
||||||
|
if (pc.iceGatheringState === 'complete') { finish(); }
|
||||||
|
});
|
||||||
|
// Never hang on a candidate that will not arrive.
|
||||||
|
setTimeout(finish, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopBroadcast(silent) {
|
||||||
|
clearInterval(statsTimer); statsTimer = null;
|
||||||
|
clearInterval(clockTimer); clockTimer = null;
|
||||||
|
|
||||||
|
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
try {
|
||||||
|
await fetch(CFG.stopUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
} catch (e) { /* the server also reaps on unpublish */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
live = false;
|
||||||
|
badge.classList.remove('is-live');
|
||||||
|
badgeText.textContent = 'READY';
|
||||||
|
goLive.classList.remove('is-live');
|
||||||
|
goLiveText.textContent = 'Go Live';
|
||||||
|
goLive.disabled = false;
|
||||||
|
stats.style.display = 'none';
|
||||||
|
|
||||||
|
if (!silent) { say('Stream ended.', 'ok'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Telemetry ---------------- */
|
||||||
|
|
||||||
|
var lastBytes = 0, lastAt = 0;
|
||||||
|
|
||||||
|
async function sampleStats() {
|
||||||
|
if (!pc) { return; }
|
||||||
|
var report = await pc.getStats();
|
||||||
|
var kbps = null, fps = null, w = null, h = null, lossPct = null;
|
||||||
|
|
||||||
|
report.forEach(function (s) {
|
||||||
|
if (s.type === 'outbound-rtp' && s.kind === 'video') {
|
||||||
|
var now = s.timestamp;
|
||||||
|
if (lastAt && s.bytesSent > lastBytes) {
|
||||||
|
kbps = Math.round(((s.bytesSent - lastBytes) * 8) / ((now - lastAt) / 1000) / 1000);
|
||||||
|
}
|
||||||
|
lastBytes = s.bytesSent; lastAt = now;
|
||||||
|
if (s.framesPerSecond) { fps = Math.round(s.framesPerSecond); }
|
||||||
|
if (s.frameWidth) { w = s.frameWidth; h = s.frameHeight; }
|
||||||
|
}
|
||||||
|
if (s.type === 'remote-inbound-rtp' && s.kind === 'video' && s.packetsLost != null && s.packetsReceived) {
|
||||||
|
lossPct = (s.packetsLost / (s.packetsLost + s.packetsReceived)) * 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (w && h) { el('lvRes').textContent = w + '×' + h; }
|
||||||
|
if (fps !== null) { el('lvFps').textContent = fps; }
|
||||||
|
if (kbps !== null) { el('lvBitrate').textContent = (kbps / 1000).toFixed(1) + ' Mbps'; }
|
||||||
|
|
||||||
|
// Report the connection honestly — a broadcaster who can see the uplink
|
||||||
|
// struggling can move rather than wonder why the picture went soft.
|
||||||
|
var health = el('lvHealth');
|
||||||
|
if (lossPct === null) { health.textContent = 'connection ok'; health.className = ''; }
|
||||||
|
else if (lossPct < 2) { health.textContent = 'connection good'; health.className = ''; }
|
||||||
|
else if (lossPct < 8) { health.textContent = 'connection weak'; health.className = 'warn'; }
|
||||||
|
else { health.textContent = 'connection poor'; health.className = 'bad'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickClock() {
|
||||||
|
var s = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
var m = Math.floor(s / 60), sec = s % 60;
|
||||||
|
el('lvTimer').textContent = (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Controls ---------------- */
|
||||||
|
|
||||||
|
goLive.addEventListener('click', function () {
|
||||||
|
if (live) { stopBroadcast(false); } else { startBroadcast(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
el('lvFlip').addEventListener('click', async function () {
|
||||||
|
facing = (facing === 'environment') ? 'user' : 'environment';
|
||||||
|
var wasLive = live;
|
||||||
|
var ok = await openCamera();
|
||||||
|
if (!ok) { return; }
|
||||||
|
|
||||||
|
// Swap the track inside the existing connection so flipping the camera
|
||||||
|
// mid-broadcast does not drop the stream.
|
||||||
|
if (wasLive && pc) {
|
||||||
|
var newTrack = stream.getVideoTracks()[0];
|
||||||
|
var sender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
|
||||||
|
if (sender && newTrack) { try { await sender.replaceTrack(newTrack); } catch (e) {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el('lvMic').addEventListener('click', function () {
|
||||||
|
if (!stream) { return; }
|
||||||
|
var track = stream.getAudioTracks()[0];
|
||||||
|
if (!track) { return; }
|
||||||
|
track.enabled = !track.enabled;
|
||||||
|
this.classList.toggle('off', !track.enabled);
|
||||||
|
this.innerHTML = track.enabled ? '<i class="bi bi-mic-fill"></i>' : '<i class="bi bi-mic-mute-fill"></i>';
|
||||||
|
});
|
||||||
|
|
||||||
|
el('lvCopy').addEventListener('click', function () {
|
||||||
|
var input = el('lvWatchUrl');
|
||||||
|
input.select();
|
||||||
|
navigator.clipboard && navigator.clipboard.writeText(input.value);
|
||||||
|
var span = this.querySelector('span');
|
||||||
|
var original = span.textContent;
|
||||||
|
span.textContent = 'Copied';
|
||||||
|
setTimeout(function () { span.textContent = original; }, 1400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A broadcaster who closes the tab must not leave a stream stuck live.
|
||||||
|
window.addEventListener('pagehide', function () {
|
||||||
|
if (live && navigator.sendBeacon) {
|
||||||
|
var body = new FormData();
|
||||||
|
body.append('_token', CFG.csrf);
|
||||||
|
navigator.sendBeacon(CFG.stopUrl, body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
openCamera();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Go Live')
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
.lc-wrap { max-width: 560px; margin: 0 auto; padding: 24px 16px 60px; }
|
||||||
|
.lc-wrap h1 { font-size: 22px; font-weight: 700; margin: 0 0 6px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.lc-sub { font-size: 13px; opacity: .6; margin: 0 0 26px; line-height: 1.6; }
|
||||||
|
.lc-field { margin-bottom: 18px; }
|
||||||
|
.lc-field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 7px; }
|
||||||
|
.lc-field input[type=text], .lc-field textarea {
|
||||||
|
width: 100%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
|
||||||
|
border-radius: 11px; padding: 11px 13px; color: inherit; font-size: 14px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.lc-field input:focus, .lc-field textarea:focus { outline: none; border-color: #ef4444; }
|
||||||
|
.lc-field textarea { min-height: 88px; resize: vertical; }
|
||||||
|
.lc-err { font-size: 12px; color: #f87171; margin-top: 6px; }
|
||||||
|
|
||||||
|
.lc-vis { display: grid; gap: 10px; }
|
||||||
|
.lc-vis label {
|
||||||
|
display: flex; gap: 12px; align-items: flex-start; padding: 13px 15px; cursor: pointer;
|
||||||
|
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; transition: border-color .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.lc-vis label:hover { background: rgba(255,255,255,.04); }
|
||||||
|
.lc-vis input { margin-top: 3px; accent-color: #ef4444; }
|
||||||
|
.lc-vis .t { font-size: 14px; font-weight: 600; }
|
||||||
|
.lc-vis .d { font-size: 12px; opacity: .6; margin-top: 2px; line-height: 1.5; }
|
||||||
|
.lc-vis input:checked + div .t { color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="lc-wrap">
|
||||||
|
<h1><i class="bi bi-broadcast" style="color:#ef4444;"></i> Go Live</h1>
|
||||||
|
<p class="lc-sub">Stream straight from your phone's camera. You'll get a link to share the moment you start.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('live.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="lc-field">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" value="{{ old('title') }}" maxlength="120" required
|
||||||
|
placeholder="What are you streaming?" autofocus>
|
||||||
|
@error('title')<div class="lc-err">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lc-field">
|
||||||
|
<label for="description">Description <span style="opacity:.5;font-weight:400;">(optional)</span></label>
|
||||||
|
<textarea id="description" name="description" maxlength="2000" placeholder="Tell viewers what to expect">{{ old('description') }}</textarea>
|
||||||
|
@error('description')<div class="lc-err">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lc-field">
|
||||||
|
<label>Who can find it</label>
|
||||||
|
<div class="lc-vis">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="public" {{ old('visibility', 'public') === 'public' ? 'checked' : '' }}>
|
||||||
|
<div>
|
||||||
|
<div class="t">Public</div>
|
||||||
|
<div class="d">Listed on the Live page — anyone can discover and watch it.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="unlisted" {{ old('visibility') === 'unlisted' ? 'checked' : '' }}>
|
||||||
|
<div>
|
||||||
|
<div class="t">Unlisted</div>
|
||||||
|
<div class="d">Not listed anywhere. Only people you send the link to can watch.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{{-- Mirror the upload-modal's Private wording: only the owner
|
||||||
|
sees it. Useful for test runs and private rehearsals. --}}
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="visibility" value="private" {{ old('visibility') === 'private' ? 'checked' : '' }}>
|
||||||
|
<div>
|
||||||
|
<div class="t">Private</div>
|
||||||
|
<div class="d">Only you can see this stream. Perfect for testing without an audience.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@error('visibility')<div class="lc-err">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="action-btn action-btn-primary" style="width:100%;justify-content:center;height:46px;">
|
||||||
|
<i class="bi bi-camera-video"></i> <span>Set up camera</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Live')
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
.li-wrap { max-width: 1300px; margin: 0 auto; padding: 16px; }
|
||||||
|
.li-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.li-head h1 { font-size: 22px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.li-head h1 .dot { width: 9px; height: 9px; border-radius: 50%; background: #ef4444; animation: liPulse 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes liPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||||
|
|
||||||
|
.li-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||||
|
|
||||||
|
.li-card { display: block; text-decoration: none; color: inherit; border-radius: 14px; overflow: hidden; background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); transition: transform .15s ease, border-color .15s ease; }
|
||||||
|
.li-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.2); }
|
||||||
|
.li-thumb { position: relative; aspect-ratio: 16/9; background: linear-gradient(135deg, #1f2937, #111827); display: grid; place-items: center; }
|
||||||
|
.li-thumb i { font-size: 32px; opacity: .3; }
|
||||||
|
.li-live { position: absolute; top: 8px; left: 8px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 4px; letter-spacing: .04em; }
|
||||||
|
.li-viewers { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,.75); color: #fff; font-size: 11px; padding: 3px 7px; border-radius: 4px; }
|
||||||
|
.li-body { padding: 12px 14px; }
|
||||||
|
.li-body h3 { font-size: 14px; font-weight: 600; margin: 0 0 5px; line-height: 1.35; }
|
||||||
|
.li-body p { font-size: 12px; opacity: .6; margin: 0; }
|
||||||
|
|
||||||
|
.li-empty { text-align: center; padding: 60px 20px; opacity: .55; }
|
||||||
|
.li-empty i { font-size: 40px; display: block; margin-bottom: 14px; opacity: .5; }
|
||||||
|
|
||||||
|
.li-section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; margin: 34px 0 14px; }
|
||||||
|
.li-status { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.1); }
|
||||||
|
.li-status.live { background: #ef4444; color: #fff; }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="li-wrap">
|
||||||
|
|
||||||
|
<div class="li-head">
|
||||||
|
<h1><span class="dot"></span> Live now</h1>
|
||||||
|
@auth
|
||||||
|
<a class="action-btn action-btn-primary" href="{{ route('live.create') }}">
|
||||||
|
<i class="bi bi-broadcast"></i> <span>Go Live</span>
|
||||||
|
</a>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($live->isEmpty())
|
||||||
|
<div class="li-empty">
|
||||||
|
<i class="bi bi-broadcast"></i>
|
||||||
|
<div>Nobody is streaming right now.</div>
|
||||||
|
@auth<div style="margin-top:8px;font-size:13px;">Be the first — hit <strong>Go Live</strong>.</div>@endauth
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="li-grid">
|
||||||
|
@foreach ($live as $s)
|
||||||
|
<a class="li-card" href="{{ route('live.watch', $s) }}">
|
||||||
|
<div class="li-thumb">
|
||||||
|
<i class="bi bi-broadcast"></i>
|
||||||
|
<span class="li-live">LIVE</span>
|
||||||
|
<span class="li-viewers"><i class="bi bi-eye"></i> {{ $s->current_viewers }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="li-body">
|
||||||
|
<h3>{{ $s->title }}</h3>
|
||||||
|
<p>{{ $s->user->name ?? 'Unknown' }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@auth
|
||||||
|
@if ($mine->isNotEmpty())
|
||||||
|
<div class="li-section-title">Your streams</div>
|
||||||
|
<div class="li-grid">
|
||||||
|
@foreach ($mine as $s)
|
||||||
|
<a class="li-card" href="{{ $s->status === 'ended' ? route('live.watch', $s) : route('live.broadcast', $s) }}">
|
||||||
|
<div class="li-thumb">
|
||||||
|
<i class="bi bi-{{ $s->isLive() ? 'broadcast' : 'camera-video-off' }}"></i>
|
||||||
|
@if ($s->isLive())<span class="li-live">LIVE</span>@endif
|
||||||
|
</div>
|
||||||
|
<div class="li-body">
|
||||||
|
<h3>{{ $s->title }}</h3>
|
||||||
|
<p>
|
||||||
|
<span class="li-status {{ $s->isLive() ? 'live' : '' }}">{{ ucfirst($s->status) }}</span>
|
||||||
|
@if ($s->started_at)
|
||||||
|
· {{ $s->formatted_duration }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $stream->title.' — Live')
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
.lw-wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||||
|
.lw-stage { position: relative; background: #000; border-radius: 16px; overflow: hidden; aspect-ratio: 16 / 9; }
|
||||||
|
.lw-stage video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
|
||||||
|
|
||||||
|
.lw-badge {
|
||||||
|
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
|
||||||
|
background: rgba(239,68,68,.92); color: #fff;
|
||||||
|
}
|
||||||
|
.lw-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: lwPulse 1.4s ease-in-out infinite; }
|
||||||
|
.lw-badge.offline { background: rgba(0,0,0,.65); }
|
||||||
|
.lw-badge.offline .dot { background: #9ca3af; animation: none; }
|
||||||
|
@keyframes lwPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||||
|
|
||||||
|
.lw-mode {
|
||||||
|
position: absolute; top: 12px; right: 12px; padding: 5px 10px; border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,.55); color: #d1d5db; font-size: 11px; backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-overlay {
|
||||||
|
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
|
||||||
|
background: rgba(0,0,0,.72); color: #e5e7eb; padding: 24px; gap: 10px;
|
||||||
|
}
|
||||||
|
.lw-overlay i { font-size: 34px; opacity: .5; display: block; margin-bottom: 10px; }
|
||||||
|
.lw-overlay .sub { font-size: 13px; opacity: .65; margin-top: 6px; }
|
||||||
|
|
||||||
|
.lw-head { display: flex; gap: 14px; align-items: flex-start; margin-top: 18px; flex-wrap: wrap; }
|
||||||
|
.lw-head h1 { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
|
||||||
|
.lw-meta { font-size: 13px; opacity: .65; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.lw-desc { margin-top: 14px; font-size: 14px; line-height: 1.65; opacity: .85; white-space: pre-wrap; }
|
||||||
|
.lw-spin { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: lwSpin .8s linear infinite; }
|
||||||
|
@keyframes lwSpin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="lw-wrap">
|
||||||
|
@php
|
||||||
|
$isEnded = $stream->status === \App\Models\LiveStream::STATUS_ENDED;
|
||||||
|
$isLive = $stream->status === \App\Models\LiveStream::STATUS_LIVE;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="lw-stage">
|
||||||
|
<video id="lwVideo" autoplay playsinline controls></video>
|
||||||
|
|
||||||
|
<div class="lw-badge" id="lwBadge"><span class="dot"></span><span id="lwBadgeText">LIVE</span></div>
|
||||||
|
<div class="lw-mode" id="lwMode" style="display:none;"></div>
|
||||||
|
|
||||||
|
{{-- Ended stream: show "Stream ended" + duration right from the
|
||||||
|
server; if a recording exists, surface a Watch Replay CTA. --}}
|
||||||
|
@if ($isEnded)
|
||||||
|
<div class="lw-overlay" id="lwOverlay" style="background: rgba(0,0,0,.86);">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-stop-circle" aria-hidden="true" style="opacity:.85;color:#f87171;"></i>
|
||||||
|
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">Stream ended</div>
|
||||||
|
@if ($stream->started_at && $stream->ended_at)
|
||||||
|
<div class="sub">
|
||||||
|
{{ $stream->ended_at->diffForHumans($stream->started_at, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}
|
||||||
|
· ended {{ $stream->ended_at->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($stream->video_id)
|
||||||
|
<a href="{{ route('videos.show', ['video' => $stream->video->getRouteKey()]) }}"
|
||||||
|
class="action-btn action-btn-primary"
|
||||||
|
style="margin-top:16px;">
|
||||||
|
<i class="bi bi-play-fill" aria-hidden="true"></i>
|
||||||
|
<span>Watch replay</span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="lw-overlay" id="lwOverlay">
|
||||||
|
<div>
|
||||||
|
<div class="lw-spin" style="margin:0 auto 12px;"></div>
|
||||||
|
<div id="lwOverlayText">Connecting…</div>
|
||||||
|
<div class="sub" id="lwOverlaySub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lw-head">
|
||||||
|
<div style="flex:1;min-width:240px;">
|
||||||
|
<h1>{{ $stream->title }}</h1>
|
||||||
|
<div class="lw-meta">
|
||||||
|
{{-- Channel name is now a real link to the uploader's channel. --}}
|
||||||
|
@if ($stream->user)
|
||||||
|
<a href="{{ route('channel', $stream->user->channel) }}"
|
||||||
|
style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px;">
|
||||||
|
<i class="bi bi-person-circle" aria-hidden="true"></i>
|
||||||
|
<span style="color:var(--text-primary);">{{ $stream->user->name }}</span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<span id="lwViewers"><i class="bi bi-eye" aria-hidden="true"></i> {{ $stream->current_viewers }} watching</span>
|
||||||
|
@if ($stream->started_at)
|
||||||
|
<span><i class="bi bi-clock" aria-hidden="true"></i> started {{ $stream->started_at->diffForHumans() }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if ($stream->description)
|
||||||
|
<div class="lw-desc">{{ $stream->description }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($isOwner)
|
||||||
|
<a class="action-btn" href="{{ route('live.broadcast', $stream) }}"><i class="bi bi-broadcast" aria-hidden="true"></i> <span>Broadcast controls</span></a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Viewer actions row — mirrors the watch page's action bar on a smaller
|
||||||
|
canvas. Share works for everyone; Subscribe is auth-gated with the
|
||||||
|
Phase-7 ?intended= bounce; the owner never sees Subscribe. --}}
|
||||||
|
@if ($stream->user && (! Auth::check() || Auth::id() !== $stream->user_id))
|
||||||
|
<div class="lw-actions" style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
|
||||||
|
@auth
|
||||||
|
@php $isSub = Auth::user()->isSubscribedTo($stream->user); @endphp
|
||||||
|
<button type="button"
|
||||||
|
class="action-btn {{ $isSub ? '' : 'action-btn-primary' }}"
|
||||||
|
onclick="(function(){
|
||||||
|
const url = {{ Js::from(route('channel.subscribe', $stream->user_id)) }};
|
||||||
|
const csrf = document.querySelector('meta[name=\'csrf-token\']')?.content || '';
|
||||||
|
fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'Accept':'application/json' }})
|
||||||
|
.then(r=>r.json()).then(d=>{
|
||||||
|
if (d.subscribed !== undefined) {
|
||||||
|
event.target.closest('button').classList.toggle('action-btn-primary', !d.subscribed);
|
||||||
|
event.target.closest('button').querySelector('span').textContent = d.subscribed ? 'Subscribed' : 'Subscribe';
|
||||||
|
}
|
||||||
|
}).catch(()=>{});
|
||||||
|
})()">
|
||||||
|
<i class="bi {{ $isSub ? 'bi-bell-fill' : 'bi-bell' }}" aria-hidden="true"></i>
|
||||||
|
<span>{{ $isSub ? 'Subscribed' : 'Subscribe' }}</span>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button type="button" class="action-btn action-btn-primary"
|
||||||
|
onclick="if(typeof showToast==='function')showToast('Sign in to subscribe','info');setTimeout(()=>{location.href='{{ route('login') }}?intended='+encodeURIComponent(location.pathname+location.search);},600);">
|
||||||
|
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
|
||||||
|
</button>
|
||||||
|
@endauth
|
||||||
|
|
||||||
|
<button type="button" class="action-btn"
|
||||||
|
onclick="(function(){
|
||||||
|
const url = window.location.href;
|
||||||
|
if (navigator.share) { navigator.share({ title: {{ Js::from($stream->title) }}, url: url }).catch(()=>{}); return; }
|
||||||
|
navigator.clipboard?.writeText(url).then(()=>{ if(typeof showToast==='function')showToast('Link copied','success'); });
|
||||||
|
})()">
|
||||||
|
<i class="bi bi-share" aria-hidden="true"></i>
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── Live chat panel ─────────────────────────────── --}}
|
||||||
|
@if ($isLive)
|
||||||
|
<div class="lw-chat" id="lwChat"
|
||||||
|
data-poll-url="{{ route('live.chat.poll', $stream) }}"
|
||||||
|
data-send-url="{{ route('live.chat.send', $stream) }}"
|
||||||
|
data-signed-in="{{ Auth::check() ? '1' : '0' }}"
|
||||||
|
data-login-url="{{ route('login') }}?intended={{ urlencode('/live/' . $stream->public_id) }}">
|
||||||
|
<div class="lw-chat-head">
|
||||||
|
<i class="bi bi-chat-dots-fill" aria-hidden="true" style="color:#ef4444;"></i>
|
||||||
|
<span>Live chat</span>
|
||||||
|
</div>
|
||||||
|
<div class="lw-chat-messages" id="lwChatMessages" aria-live="polite" aria-atomic="false">
|
||||||
|
<div style="color:var(--text-secondary);font-size:13px;text-align:center;padding:16px;">Say hi 👋</div>
|
||||||
|
</div>
|
||||||
|
<form class="lw-chat-form" id="lwChatForm" onsubmit="return lwChatSubmit(event);">
|
||||||
|
@csrf
|
||||||
|
<input type="text" id="lwChatInput" maxlength="400"
|
||||||
|
placeholder="{{ Auth::check() ? 'Say something…' : 'Sign in to chat' }}"
|
||||||
|
{{ Auth::check() ? '' : 'disabled' }}
|
||||||
|
aria-label="Chat message">
|
||||||
|
<button type="submit" aria-label="Send"
|
||||||
|
{{ Auth::check() ? '' : 'disabled' }}>
|
||||||
|
<i class="bi bi-send-fill" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.lw-chat {
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
.lw-chat-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-weight: 600; font-size: 14px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.lw-chat-messages {
|
||||||
|
flex: 1; overflow-y: auto; padding: 10px 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.lw-chat-msg {
|
||||||
|
font-size: 13px; line-height: 1.4;
|
||||||
|
display: flex; gap: 6px; align-items: flex-start;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.lw-chat-msg .n { font-weight: 600; color: #93c5fd; }
|
||||||
|
.lw-chat-msg .b { color: var(--text-primary); word-break: break-word; min-width: 0; }
|
||||||
|
.lw-chat-form {
|
||||||
|
display: flex; gap: 8px; padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.lw-chat-form input {
|
||||||
|
flex: 1; background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color); border-radius: 20px;
|
||||||
|
padding: 8px 14px; color: var(--text-primary); font-size: 13px;
|
||||||
|
}
|
||||||
|
.lw-chat-form input:focus { outline: none; border-color: #ef4444; }
|
||||||
|
.lw-chat-form button {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: #ef4444; color: #fff; border: none;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.lw-chat-form button:disabled {
|
||||||
|
background: var(--border-color); color: var(--text-secondary); cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const panel = document.getElementById('lwChat');
|
||||||
|
if (!panel) return;
|
||||||
|
const list = document.getElementById('lwChatMessages');
|
||||||
|
const input = document.getElementById('lwChatInput');
|
||||||
|
const form = document.getElementById('lwChatForm');
|
||||||
|
const pollU = panel.dataset.pollUrl;
|
||||||
|
const sendU = panel.dataset.sendUrl;
|
||||||
|
const loginU = panel.dataset.loginUrl;
|
||||||
|
const authed = panel.dataset.signedIn === '1';
|
||||||
|
let since = 0, timer = null, firstPaint = true;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function append(msg) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'lw-chat-msg';
|
||||||
|
row.innerHTML = '<span class="n">' + escapeHtml(msg.name) + ':</span>'
|
||||||
|
+ ' <span class="b">' + escapeHtml(msg.body) + '</span>';
|
||||||
|
list.appendChild(row);
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
}
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(pollU + '?since=' + since, { credentials: 'same-origin', headers: {'Accept':'application/json'} });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.messages && d.messages.length) {
|
||||||
|
if (firstPaint) { list.innerHTML = ''; firstPaint = false; }
|
||||||
|
d.messages.forEach(m => { append(m); if (m.id > since) since = m.id; });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
window.lwChatSubmit = function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!authed) { location.href = loginU; return false; }
|
||||||
|
const body = (input.value || '').trim();
|
||||||
|
if (!body) return false;
|
||||||
|
input.disabled = true;
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
fetch(sendU, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.message) { append(d.message); since = Math.max(since, d.message.id); }
|
||||||
|
input.value = '';
|
||||||
|
}).catch(() => {
|
||||||
|
if (typeof showToast === 'function') showToast('Could not send message.', 'error');
|
||||||
|
}).finally(() => { input.disabled = false; input.focus(); });
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
timer = setInterval(poll, 3000);
|
||||||
|
window.addEventListener('beforeunload', () => clearInterval(timer));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
@unless($isEnded)
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var CFG = {
|
||||||
|
whep: @json($whepUrl),
|
||||||
|
hls: @json($hlsUrl),
|
||||||
|
statusUrl: @json(route('live.status', $stream)),
|
||||||
|
iceServers: @json($iceServers),
|
||||||
|
isLive: @json($stream->isLive())
|
||||||
|
};
|
||||||
|
|
||||||
|
var video = document.getElementById('lwVideo');
|
||||||
|
var overlay = document.getElementById('lwOverlay');
|
||||||
|
var overlayText = document.getElementById('lwOverlayText');
|
||||||
|
var overlaySub = document.getElementById('lwOverlaySub');
|
||||||
|
var badge = document.getElementById('lwBadge');
|
||||||
|
var badgeText = document.getElementById('lwBadgeText');
|
||||||
|
var modeChip = document.getElementById('lwMode');
|
||||||
|
|
||||||
|
var pc = null, hls = null, started = false;
|
||||||
|
|
||||||
|
function showOverlay(text, sub, spinning) {
|
||||||
|
overlay.style.display = 'grid';
|
||||||
|
overlayText.textContent = text;
|
||||||
|
overlaySub.textContent = sub || '';
|
||||||
|
overlay.querySelector('.lw-spin').style.display = spinning === false ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
function hideOverlay() { overlay.style.display = 'none'; }
|
||||||
|
|
||||||
|
function setMode(label) {
|
||||||
|
modeChip.textContent = label;
|
||||||
|
modeChip.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOffline() {
|
||||||
|
badge.classList.add('offline');
|
||||||
|
badgeText.textContent = 'OFFLINE';
|
||||||
|
showOverlay('This stream has ended', 'The recording may appear here shortly.', false);
|
||||||
|
teardown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
|
||||||
|
if (hls) { try { hls.destroy(); } catch (e) {} hls = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- WHEP: sub-second path ---------------- */
|
||||||
|
|
||||||
|
// Tried first because it is the lowest latency. If anything about it fails
|
||||||
|
// — no UDP path, a restrictive network, an old browser — we fall through
|
||||||
|
// to LL-HLS rather than showing the viewer an error.
|
||||||
|
async function tryWhep() {
|
||||||
|
if (!window.RTCPeerConnection) { return false; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: CFG.iceServers || [], bundlePolicy: 'max-bundle' });
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
var streamIn = new MediaStream();
|
||||||
|
pc.addEventListener('track', function (ev) {
|
||||||
|
streamIn.addTrack(ev.track);
|
||||||
|
video.srcObject = streamIn;
|
||||||
|
});
|
||||||
|
|
||||||
|
var offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
await gathered(pc);
|
||||||
|
|
||||||
|
var res = await fetch(CFG.whep, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/sdp' },
|
||||||
|
body: pc.localDescription.sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) { throw new Error('whep ' + res.status); }
|
||||||
|
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
||||||
|
|
||||||
|
return await new Promise(function (resolve) {
|
||||||
|
var settled = false;
|
||||||
|
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', function () {
|
||||||
|
if (pc.connectionState === 'connected') { decide(true); }
|
||||||
|
if (pc.connectionState === 'failed') { decide(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't let a half-open connection stall the viewer forever.
|
||||||
|
setTimeout(function () { decide(pc && pc.connectionState === 'connected'); }, 6000);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gathered(pc) {
|
||||||
|
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var done = false;
|
||||||
|
function finish() { if (!done) { done = true; resolve(); } }
|
||||||
|
pc.addEventListener('icegatheringstatechange', function () {
|
||||||
|
if (pc.iceGatheringState === 'complete') { finish(); }
|
||||||
|
});
|
||||||
|
setTimeout(finish, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- LL-HLS: the path that always works ---------------- */
|
||||||
|
|
||||||
|
function loadHlsLibrary() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (window.Hls) { return resolve(); }
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryHls() {
|
||||||
|
// Safari plays HLS natively and does it better than hls.js does.
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = CFG.hls;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await loadHlsLibrary(); } catch (e) { return false; }
|
||||||
|
if (!window.Hls || !window.Hls.isSupported()) { return false; }
|
||||||
|
|
||||||
|
return await new Promise(function (resolve) {
|
||||||
|
hls = new Hls({ lowLatencyMode: true, backBufferLength: 30, liveSyncDurationCount: 2 });
|
||||||
|
var settled = false;
|
||||||
|
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(function () {}); decide(true); });
|
||||||
|
hls.on(Hls.Events.ERROR, function (_e, data) {
|
||||||
|
if (data && data.fatal) { decide(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(CFG.hls);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
setTimeout(function () { decide(false); }, 12000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Orchestration ---------------- */
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (started) { return; }
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
showOverlay('Connecting…', 'Finding the fastest route');
|
||||||
|
|
||||||
|
if (await tryWhep()) {
|
||||||
|
setMode('Ultra-low latency');
|
||||||
|
hideOverlay();
|
||||||
|
video.play().catch(function () {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown();
|
||||||
|
showOverlay('Connecting…', 'Using the standard route');
|
||||||
|
|
||||||
|
if (await tryHls()) {
|
||||||
|
setMode('Low latency');
|
||||||
|
hideOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
started = false;
|
||||||
|
showOverlay('Could not start playback', 'The stream may still be starting up. Retrying…', false);
|
||||||
|
setTimeout(function () { if (!started) { connect(); } }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Live state ---------------- */
|
||||||
|
|
||||||
|
// Polling is a deliberate, cheap fallback: this platform has no realtime
|
||||||
|
// channel of its own, and one small request every 10s per viewer is far
|
||||||
|
// less costly than a socket layer built only for this page.
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
var res = await fetch(CFG.statusUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!res.ok) { return; }
|
||||||
|
var data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('lwViewers').innerHTML = '<i class="bi bi-eye"></i> ' + data.viewers + ' watching';
|
||||||
|
|
||||||
|
if (data.live && !started) { connect(); }
|
||||||
|
if (!data.live && data.status === 'ended') { setOffline(); }
|
||||||
|
} catch (e) { /* transient */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CFG.isLive) {
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
showOverlay('Waiting for the broadcast to start', 'This page will begin automatically.', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(pollStatus, 10000);
|
||||||
|
window.addEventListener('pagehide', teardown);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endunless
|
||||||
|
@endsection
|
||||||
@@ -37,14 +37,25 @@ a.pag-item:hover {
|
|||||||
@endonce
|
@endonce
|
||||||
<nav class="pag-nav" aria-label="Pagination">
|
<nav class="pag-nav" aria-label="Pagination">
|
||||||
|
|
||||||
{{-- Prev --}}
|
{{-- First --}}
|
||||||
@if ($paginator->onFirstPage())
|
@if ($paginator->onFirstPage())
|
||||||
<span class="pag-item disabled" aria-disabled="true">
|
<span class="pag-item disabled" aria-disabled="true" title="First page">
|
||||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
|
<a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
|
||||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Prev --}}
|
||||||
|
@if ($paginator->onFirstPage())
|
||||||
|
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
|
||||||
|
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
|
||||||
|
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -66,12 +77,23 @@ a.pag-item:hover {
|
|||||||
|
|
||||||
{{-- Next --}}
|
{{-- Next --}}
|
||||||
@if ($paginator->hasMorePages())
|
@if ($paginator->hasMorePages())
|
||||||
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
|
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
|
||||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<span class="pag-item disabled" aria-disabled="true">
|
<span class="pag-item disabled" aria-disabled="true" title="Next page">
|
||||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Last --}}
|
||||||
|
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
|
||||||
|
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
|
||||||
|
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="pag-item disabled" aria-disabled="true" title="Last page">
|
||||||
|
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Studio | ' . config('app.name'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<style>
|
||||||
|
.st-head { padding: 24px 32px 8px; }
|
||||||
|
.st-head h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.st-head h1 i { color: #ef4444; }
|
||||||
|
.st-head p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
|
||||||
|
|
||||||
|
.st-stat-cards {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 10px; margin: 12px 0 20px;
|
||||||
|
}
|
||||||
|
.st-stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px; padding: 12px 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.st-stat-card .k { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); }
|
||||||
|
.st-stat-card .v { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.st-filter-bar {
|
||||||
|
display: flex; gap: 8px; overflow-x: auto;
|
||||||
|
padding: 4px 0; scrollbar-width: none;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.st-filter-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.st-filter-pill {
|
||||||
|
padding: 6px 12px; border-radius: 999px;
|
||||||
|
background: var(--bg-secondary); color: var(--text-primary);
|
||||||
|
font-size: 12px; font-weight: 600; text-decoration: none;
|
||||||
|
white-space: nowrap; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.st-filter-pill:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
|
||||||
|
.st-filter-pill.active { background: #ef4444; color: #fff; }
|
||||||
|
|
||||||
|
.st-toolbar {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 10px 14px; margin-bottom: 8px;
|
||||||
|
background: var(--bg-secondary); border-radius: 10px;
|
||||||
|
}
|
||||||
|
.st-toolbar.hidden { display: none; }
|
||||||
|
.st-toolbar-count { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.st-toolbar-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.st-visibility-select {
|
||||||
|
background: var(--bg-dark); color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color); border-radius: 6px;
|
||||||
|
padding: 6px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.st-table th, .st-table td {
|
||||||
|
padding: 10px 12px; border-bottom: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.st-table th { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.st-table tbody tr:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.st-table .st-check { width: 32px; }
|
||||||
|
.st-table .st-thumb { width: 76px; height: 44px; object-fit: cover; border-radius: 6px; }
|
||||||
|
.st-table .st-title-cell { display: flex; gap: 10px; align-items: flex-start; max-width: 320px; }
|
||||||
|
.st-table .st-title-name {
|
||||||
|
font-weight: 500; color: var(--text-primary);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.st-table .st-title-desc {
|
||||||
|
font-size: 11px; color: var(--text-secondary); margin-top: 2px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.st-badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.st-badge.pub { background: rgba(34,197,94,.15); color: #4ade80; }
|
||||||
|
.st-badge.unl { background: rgba(234,179,8,.15); color: #fde047; }
|
||||||
|
.st-badge.priv { background: rgba(100,116,139,.2); color: #cbd5e1; }
|
||||||
|
.st-status { font-size: 11px; color: var(--text-secondary); }
|
||||||
|
.st-row-link { color: var(--text-primary); text-decoration: none; }
|
||||||
|
.st-row-link:hover { color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="st-head">
|
||||||
|
<h1><i class="bi bi-sliders" aria-hidden="true"></i> Studio</h1>
|
||||||
|
<p>Manage every video you've uploaded. Select rows for bulk visibility changes.</p>
|
||||||
|
|
||||||
|
<div class="st-stat-cards">
|
||||||
|
<div class="st-stat-card"><span class="k">Total videos</span> <span class="v">{{ number_format($totals['total']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Public</span> <span class="v">{{ number_format($totals['public']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Unlisted</span> <span class="v">{{ number_format($totals['unlisted']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Private</span> <span class="v">{{ number_format($totals['private']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Processing</span> <span class="v">{{ number_format($totals['processing']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Failed</span> <span class="v">{{ number_format($totals['failed']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Total views</span> <span class="v">{{ number_format($totals['total_views']) }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="st-filter-bar" role="navigation" aria-label="Filter videos">
|
||||||
|
@foreach (['all'=>'All', 'public'=>'Public', 'unlisted'=>'Unlisted', 'private'=>'Private', 'shorts'=>'Shorts', 'draft'=>'Processing / Failed'] as $k => $label)
|
||||||
|
<a href="{{ route('studio', ['filter' => $k]) }}"
|
||||||
|
class="st-filter-pill {{ $filter === $k ? 'active' : '' }}"
|
||||||
|
@if($filter === $k) aria-current="page" @endif>{{ $label }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 0 32px 32px;">
|
||||||
|
<div class="st-toolbar hidden" id="stToolbar" aria-live="polite">
|
||||||
|
<span class="st-toolbar-count"><span id="stSelectedCount">0</span> selected</span>
|
||||||
|
<div class="st-toolbar-actions">
|
||||||
|
<select class="st-visibility-select" id="stBulkVisibility">
|
||||||
|
<option value="">Change visibility…</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="action-btn" onclick="stApplyBulkVisibility()">Apply</button>
|
||||||
|
<button type="button" class="action-btn action-btn-danger" onclick="stApplyBulkDelete()">Delete selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($videos->isEmpty())
|
||||||
|
<div class="yt-empty" style="text-align:center; padding:60px 20px;">
|
||||||
|
<i class="bi bi-camera-video" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:12px;" aria-hidden="true"></i>
|
||||||
|
<h2>No videos in this filter</h2>
|
||||||
|
<p style="color:var(--text-secondary);">Switch filters above or upload a new video from the sidebar.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<table class="st-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="st-check">
|
||||||
|
<input type="checkbox" id="stSelectAll" onclick="stToggleAll(this)" aria-label="Select all rows">
|
||||||
|
</th>
|
||||||
|
<th>Video</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Likes</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($videos as $v)
|
||||||
|
<tr>
|
||||||
|
<td class="st-check"><input type="checkbox" class="st-row-check" value="{{ $v->id }}" onclick="stUpdateToolbar()" aria-label="Select {{ $v->title }}"></td>
|
||||||
|
<td>
|
||||||
|
<div class="st-title-cell">
|
||||||
|
@if ($v->thumbnail)
|
||||||
|
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="st-thumb" alt="" loading="lazy">
|
||||||
|
@else
|
||||||
|
<div class="st-thumb" style="background:#1a1a1a;"></div>
|
||||||
|
@endif
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<a class="st-row-link" href="{{ route('videos.show', $v) }}" target="_blank">
|
||||||
|
<div class="st-title-name" title="{{ $v->title }}">{{ $v->title }}</div>
|
||||||
|
</a>
|
||||||
|
@if ($v->description)
|
||||||
|
@php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp
|
||||||
|
<div class="st-title-desc" title="{{ $plain }}">{{ Str::limit($plain, 70) }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@switch($v->visibility)
|
||||||
|
@case('public') <span class="st-badge pub">Public</span> @break
|
||||||
|
@case('unlisted') <span class="st-badge unl">Unlisted</span> @break
|
||||||
|
@case('private') <span class="st-badge priv">Private</span> @break
|
||||||
|
@endswitch
|
||||||
|
@if ($v->status !== 'ready')
|
||||||
|
<div class="st-status">{{ ucfirst($v->status) }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ number_format($v->view_count) }}</td>
|
||||||
|
<td>{{ number_format($v->likes()->count()) }}</td>
|
||||||
|
<td>{{ number_format($v->comments()->count()) }}</td>
|
||||||
|
<td>{{ $v->created_at->format('M j, Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px;">{{ $videos->links() }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function stCheckedIds() {
|
||||||
|
return Array.from(document.querySelectorAll('.st-row-check:checked')).map(c => parseInt(c.value, 10));
|
||||||
|
}
|
||||||
|
function stUpdateToolbar() {
|
||||||
|
const n = stCheckedIds().length;
|
||||||
|
const t = document.getElementById('stToolbar');
|
||||||
|
document.getElementById('stSelectedCount').textContent = n;
|
||||||
|
t.classList.toggle('hidden', n === 0);
|
||||||
|
}
|
||||||
|
function stToggleAll(cb) {
|
||||||
|
document.querySelectorAll('.st-row-check').forEach(c => { c.checked = cb.checked; });
|
||||||
|
stUpdateToolbar();
|
||||||
|
}
|
||||||
|
async function stApplyBulkVisibility() {
|
||||||
|
const ids = stCheckedIds();
|
||||||
|
const sel = document.getElementById('stBulkVisibility');
|
||||||
|
if (!ids.length || !sel.value) return;
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route('studio.bulkVisibility') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_ids: ids, visibility: sel.value }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (typeof showToast === 'function') showToast('Updated ' + d.updated + ' video(s).', 'success');
|
||||||
|
setTimeout(() => location.reload(), 400);
|
||||||
|
} catch { if (typeof showToast === 'function') showToast('Could not update videos.', 'error'); }
|
||||||
|
}
|
||||||
|
async function stApplyBulkDelete() {
|
||||||
|
const ids = stCheckedIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!window.confirm('Delete ' + ids.length + ' video(s)? This cannot be undone.')) return;
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route('studio.bulkDelete') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (typeof showToast === 'function') showToast('Deleted ' + d.deleted + ' video(s).', 'success');
|
||||||
|
setTimeout(() => location.reload(), 400);
|
||||||
|
} catch { if (typeof showToast === 'function') showToast('Could not delete videos.', 'error'); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@@ -122,7 +122,8 @@ $headerSocialMap = [
|
|||||||
<div class="ch-meta-row">
|
<div class="ch-meta-row">
|
||||||
<span class="ch-meta-item">
|
<span class="ch-meta-item">
|
||||||
<i class="bi bi-camera-video-fill"></i>
|
<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>
|
||||||
<span class="ch-meta-sep">·</span>
|
<span class="ch-meta-sep">·</span>
|
||||||
<span class="ch-meta-item">
|
<span class="ch-meta-item">
|
||||||
@@ -136,7 +137,14 @@ $headerSocialMap = [
|
|||||||
<i class="bi bi-people-fill"></i>
|
<i class="bi bi-people-fill"></i>
|
||||||
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
|
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
|
||||||
</span>
|
</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-sep">·</span>
|
||||||
<span class="ch-meta-item">
|
<span class="ch-meta-item">
|
||||||
<i class="bi bi-geo-alt-fill"></i>
|
<i class="bi bi-geo-alt-fill"></i>
|
||||||
@@ -148,7 +156,7 @@ $headerSocialMap = [
|
|||||||
<i class="bi bi-calendar3"></i>
|
<i class="bi bi-calendar3"></i>
|
||||||
Joined {{ $user->created_at->format('M Y') }}
|
Joined {{ $user->created_at->format('M Y') }}
|
||||||
</span>
|
</span>
|
||||||
@if($user->birthday)
|
@if($isOwner && $user->birthday)
|
||||||
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
|
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
|
||||||
<span class="ch-meta-sep">·</span>
|
<span class="ch-meta-sep">·</span>
|
||||||
<span class="ch-meta-item">
|
<span class="ch-meta-item">
|
||||||
@@ -156,13 +164,13 @@ $headerSocialMap = [
|
|||||||
{{ $age }} yrs
|
{{ $age }} yrs
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@if($user->gender === 'male')
|
@if($isOwner && $user->gender === 'male')
|
||||||
<span class="ch-meta-sep">·</span>
|
<span class="ch-meta-sep">·</span>
|
||||||
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
|
<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>
|
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
|
||||||
Male
|
Male
|
||||||
</span>
|
</span>
|
||||||
@elseif($user->gender === 'female')
|
@elseif($isOwner && $user->gender === 'female')
|
||||||
<span class="ch-meta-sep">·</span>
|
<span class="ch-meta-sep">·</span>
|
||||||
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
|
<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>
|
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Horoscope + Compatibility strip & Social buttons side by side --}}
|
{{-- Horoscope + Compatibility strip & Social buttons side by side.
|
||||||
@if($horoscope || $socialLinks->isNotEmpty())
|
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
|
@php
|
||||||
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
|
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
|
||||||
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
|
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
|
||||||
|
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
|
||||||
|
@endphp
|
||||||
|
@if($showHoroStrip || $socialLinks->isNotEmpty())
|
||||||
|
@php
|
||||||
if ($showCompat) {
|
if ($showCompat) {
|
||||||
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
|
$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')));
|
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
|
<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;"
|
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
|
||||||
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
|
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
|
||||||
<div class="ch-horo-sign-part">
|
<div class="ch-horo-sign-part">
|
||||||
@@ -361,10 +376,15 @@ $headerSocialMap = [
|
|||||||
<span class="ch-tab-badge">{{ $videos->count() }}</span>
|
<span class="ch-tab-badge">{{ $videos->count() }}</span>
|
||||||
@endif
|
@endif
|
||||||
</button>
|
</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)">
|
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
|
||||||
Shorts
|
Shorts
|
||||||
|
@if($shorts->count() > 0)
|
||||||
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
|
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
|
||||||
|
@endif
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if(($playlists && $playlists->count() > 0) || $isOwner)
|
@if(($playlists && $playlists->count() > 0) || $isOwner)
|
||||||
@@ -394,9 +414,9 @@ $headerSocialMap = [
|
|||||||
{{-- ── LEFT: composer + feed ── --}}
|
{{-- ── LEFT: composer + feed ── --}}
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{{-- Composer (owner only) --}}
|
{{-- Composer (owner only, hidden when previewing as a visitor) --}}
|
||||||
@auth
|
@auth
|
||||||
@if(Auth::id() === $user->id)
|
@if(Auth::id() === $user->id && !$preview)
|
||||||
<div class="ch-composer">
|
<div class="ch-composer">
|
||||||
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
|
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -528,7 +548,7 @@ $headerSocialMap = [
|
|||||||
<div class="ch-post-video-info">
|
<div class="ch-post-video-info">
|
||||||
<div class="ch-post-video-title">{{ $v->title }}</div>
|
<div class="ch-post-video-title">{{ $v->title }}</div>
|
||||||
<div class="ch-post-video-meta">
|
<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->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
|
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -663,24 +683,74 @@ $headerSocialMap = [
|
|||||||
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
|
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||||
<span class="ch-sort-label">Sort</span>
|
<span class="ch-sort-label">Sort</span>
|
||||||
<div class="ch-sort-pills">
|
<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>
|
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>
|
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>
|
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-grid" id="chVidGrid">
|
<div class="yt-video-grid" id="chVidGrid">
|
||||||
@foreach($videos as $video)
|
@foreach($videos as $video)
|
||||||
@include('components.video-card', ['video' => $video])
|
@include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
|
||||||
@endforeach
|
@endforeach
|
||||||
<div class="ch-vid-no-results" id="chVidNoResults">
|
<div class="ch-vid-no-results" id="chVidNoResults">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
<span>No videos match your search</span>
|
<span>No videos match your search</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
|
||||||
|
fetches the next slice when scrolled into view. A manual Load More
|
||||||
|
button is the fallback for browsers without IntersectionObserver. --}}
|
||||||
|
<div id="chVidSentinel"
|
||||||
|
data-username="{{ $user->username }}"
|
||||||
|
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
|
||||||
|
style="height:1px;"></div>
|
||||||
|
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
|
||||||
|
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const grid = document.getElementById('chVidGrid');
|
||||||
|
const sentinel = document.getElementById('chVidSentinel');
|
||||||
|
const btnWrap = document.getElementById('chVidLoadMoreWrap');
|
||||||
|
const btn = document.getElementById('chVidLoadMoreBtn');
|
||||||
|
if (!grid || !sentinel) return;
|
||||||
|
let cursor = '', busy = false, done = false;
|
||||||
|
const url = sentinel.dataset.moreUrl;
|
||||||
|
async function loadMore() {
|
||||||
|
if (busy || done) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
if (cursor) u.searchParams.set('cursor', cursor);
|
||||||
|
const r = await fetch(u.toString(), {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
|
||||||
|
cursor = d.next_cursor || '';
|
||||||
|
done = !d.has_more;
|
||||||
|
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
|
||||||
|
} catch (e) {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
btn?.addEventListener('click', loadMore);
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
new IntersectionObserver(entries => {
|
||||||
|
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||||
|
}, { rootMargin: '400px 0px' }).observe(sentinel);
|
||||||
|
} else {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@else
|
@else
|
||||||
<div class="ch-empty">
|
<div class="ch-empty">
|
||||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||||
@@ -699,8 +769,15 @@ $headerSocialMap = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
|
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
|
||||||
@if($shorts->count() > 0)
|
@if($isOwner || $shorts->count() > 0)
|
||||||
<div class="ch-tab-content" id="tab-shorts">
|
<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">
|
<div class="ch-shorts-grid">
|
||||||
@foreach($shorts as $short)
|
@foreach($shorts as $short)
|
||||||
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
||||||
@@ -722,6 +799,7 @@ $headerSocialMap = [
|
|||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -753,8 +831,8 @@ $headerSocialMap = [
|
|||||||
@endif
|
@endif
|
||||||
<div class="ch-playlist-overlay"></div>
|
<div class="ch-playlist-overlay"></div>
|
||||||
<div class="ch-playlist-count">
|
<div class="ch-playlist-count">
|
||||||
<i class="bi bi-play-fill"></i>
|
<i class="bi bi-play-fill" aria-hidden="true"></i>
|
||||||
{{ $playlist->video_count }} videos
|
{{ $playlist->video_count }} {{ Str::plural('video', $playlist->video_count) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ch-playlist-name">{{ $playlist->name }}</div>
|
<div class="ch-playlist-name">{{ $playlist->name }}</div>
|
||||||
@@ -768,7 +846,13 @@ $headerSocialMap = [
|
|||||||
@else
|
@else
|
||||||
<i class="bi bi-globe"></i> Public
|
<i class="bi bi-globe"></i> Public
|
||||||
@endif
|
@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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -1057,7 +1141,7 @@ $headerSocialMap = [
|
|||||||
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
|
<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-cards">
|
||||||
<div class="ch-stat-card">
|
<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 class="ch-stat-card-key">Total Views</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ch-stat-card">
|
<div class="ch-stat-card">
|
||||||
@@ -1171,7 +1255,13 @@ if ($oldLinks) {
|
|||||||
<div class="ch-settings-field">
|
<div class="ch-settings-field">
|
||||||
<label class="ch-settings-label">Gender</label>
|
<label class="ch-settings-label">Gender</label>
|
||||||
<select name="gender" class="ch-settings-input" style="cursor:pointer;">
|
<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="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
|
||||||
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</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>
|
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
|
||||||
@@ -1338,9 +1428,14 @@ if ($oldLinks) {
|
|||||||
@php
|
@php
|
||||||
$notifPrefs = $user->notification_preferences ?? [];
|
$notifPrefs = $user->notification_preferences ?? [];
|
||||||
$notifDefs = \App\Models\User::notifDefaults();
|
$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 {
|
function notifVal(array $prefs, array $defs, string $key): bool {
|
||||||
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
|
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
<div class="ch-settings-card" id="notifPrefsCard">
|
<div class="ch-settings-card" id="notifPrefsCard">
|
||||||
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
|
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Watch History | TAKEONE')
|
@section('title', 'Watch History | ' . config('app.name'))
|
||||||
|
|
||||||
@section('extra_styles')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Liked Videos | TAKEONE')
|
@section('title', 'Liked Videos | ' . config('app.name'))
|
||||||
|
|
||||||
@section('extra_styles')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
@@ -146,7 +146,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
|
{{-- Cursor paginator has no "N pages" concept — just prev/next. --}}
|
||||||
|
<div class="mt-4">{{ $videos->links() }}</div>
|
||||||
@endif
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@@ -342,10 +342,17 @@
|
|||||||
/* ══════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════
|
||||||
VIDEO GRID
|
VIDEO GRID
|
||||||
══════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════ */
|
||||||
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
/* Fluid grid: as many 260-px columns as fit, each one stretching to fill
|
||||||
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
the leftover space. Removes the three fixed breakpoints that produced
|
||||||
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } }
|
different card widths at 1101px vs 1500px, and `min-width:0` on every
|
||||||
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } }
|
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
|
SHORTS GRID
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Profile | TAKEONE')
|
@section('title', 'Profile | ' . config('app.name'))
|
||||||
|
|
||||||
@section('extra_styles')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Settings | TAKEONE')
|
@section('title', 'Settings | ' . config('app.name'))
|
||||||
|
|
||||||
@section('extra_styles')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -42,6 +42,34 @@
|
|||||||
}
|
}
|
||||||
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
|
.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; }
|
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||||
|
|
||||||
/* Language chips */
|
/* Language chips */
|
||||||
@@ -59,21 +87,34 @@
|
|||||||
<div class="yt-filter-bar-wrap">
|
<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 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>
|
<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') }}"
|
<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') }}"
|
<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']) }}"
|
<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']) }}"
|
<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') }}"
|
<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']) }}"
|
<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']) }}"
|
<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 ?? []))
|
@if(!empty($langChips ?? []))
|
||||||
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
||||||
@@ -81,6 +122,7 @@
|
|||||||
@foreach($langChips as $chip)
|
@foreach($langChips as $chip)
|
||||||
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
||||||
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
||||||
|
@if($activeLang === $chip['code']) aria-current="page"@endif
|
||||||
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
||||||
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
||||||
<span>{{ $chip['name'] }}</span>
|
<span>{{ $chip['name'] }}</span>
|
||||||
@@ -100,7 +142,8 @@
|
|||||||
@php
|
@php
|
||||||
$searchShorts = $shorts ?? collect();
|
$searchShorts = $shorts ?? collect();
|
||||||
$searchPlaylists = $playlists ?? collect();
|
$searchPlaylists = $playlists ?? collect();
|
||||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
|
$searchChannels = $channels ?? collect();
|
||||||
|
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="search-info">
|
<div class="search-info">
|
||||||
@@ -108,11 +151,33 @@
|
|||||||
<p>
|
<p>
|
||||||
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
||||||
@if($searchShorts->count()) · {{ $searchShorts->count() }} short{{ $searchShorts->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($searchPlaylists->count()) · {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
|
||||||
@if($totalResults === 0) No results found @endif
|
@if($totalResults === 0) No results found @endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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())
|
@if($videos->count())
|
||||||
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
||||||
<div class="yt-video-grid">
|
<div class="yt-video-grid">
|
||||||
@@ -172,6 +237,60 @@
|
|||||||
══════════════════════════════════════════════ --}}
|
══════════════════════════════════════════════ --}}
|
||||||
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
|
@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())
|
@if(isset($feedItems) && $feedItems->count())
|
||||||
<div class="yt-video-grid">
|
<div class="yt-video-grid">
|
||||||
@foreach($feedItems as $entry)
|
@foreach($feedItems as $entry)
|
||||||
@@ -211,6 +330,17 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@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 --}}
|
</div>{{-- /filter-content --}}
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
@@ -354,5 +484,69 @@
|
|||||||
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
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>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
@@ -7,7 +7,12 @@
|
|||||||
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
||||||
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
||||||
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
$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
|
@endphp
|
||||||
@if ($showBox)
|
@if ($showBox)
|
||||||
<style>
|
<style>
|
||||||
@@ -36,6 +41,27 @@
|
|||||||
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
|
.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 i { font-size:14px; transition:transform .2s ease; }
|
||||||
.vdb-show-more.expanded i { transform:rotate(180deg); }
|
.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>
|
</style>
|
||||||
|
|
||||||
<div class="vdb-wrap" id="vdbWrap">
|
<div class="vdb-wrap" id="vdbWrap">
|
||||||
@@ -51,6 +77,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vdb-panel active" id="vdb-about">
|
<div class="vdb-panel active" id="vdb-about">
|
||||||
|
@if($aboutPrepend !== null)
|
||||||
|
{!! $aboutPrepend !!}
|
||||||
|
@endif
|
||||||
<div class="vdb-meta">
|
<div class="vdb-meta">
|
||||||
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -60,11 +89,21 @@
|
|||||||
@if(isset($descriptionSlot))
|
@if(isset($descriptionSlot))
|
||||||
{!! $descriptionSlot !!}
|
{!! $descriptionSlot !!}
|
||||||
@elseif($renderedDescription !== '')
|
@elseif($renderedDescription !== '')
|
||||||
|
<div id="vdbDescSplit" class="vdb-desc-split">
|
||||||
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
<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>
|
<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
|
@else
|
||||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|
||||||
@if($isVideoOwner)
|
@if($isVideoOwner)
|
||||||
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
|
|||||||
};
|
};
|
||||||
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
||||||
window.addEventListener('load', 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>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
@@ -37,10 +37,10 @@
|
|||||||
{{-- RED / AKA --}}
|
{{-- 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="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 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="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">
|
<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>
|
<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>
|
||||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
<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="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">
|
<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="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>
|
||||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
<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="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>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,8 +78,10 @@
|
|||||||
el.textContent = val || '';
|
el.textContent = val || '';
|
||||||
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
||||||
};
|
};
|
||||||
// Replace the placeholder "CLUB LOGO" text with a real <img>
|
// When a club_logo path is set, drop a real <img> into the empty
|
||||||
// (or leave the placeholder if no image). Never touches inline styles.
|
// 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 setImage = (name, path) => {
|
||||||
const el = $(name); if (!el) return;
|
const el = $(name); if (!el) return;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
@@ -90,8 +92,9 @@
|
|||||||
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
||||||
el.appendChild(img);
|
el.appendChild(img);
|
||||||
};
|
};
|
||||||
// Swap the placeholder "FLAG" box for a real flag image by pointing
|
// When an ISO2 is set, point the flag box's background-image at
|
||||||
// its background-image at the self-hosted flag-icons SVG.
|
// the self-hosted flag-icons SVG. When it isn't, the empty box
|
||||||
|
// stays empty — no "FLAG" placeholder text.
|
||||||
const setFlag = (name, iso2) => {
|
const setFlag = (name, iso2) => {
|
||||||
const el = $(name); if (!el) return;
|
const el = $(name); if (!el) return;
|
||||||
const code = (iso2 || '').toLowerCase();
|
const code = (iso2 || '').toLowerCase();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Shorts - ' . config('app.name'))
|
@section('title', 'Shorts | ' . config('app.name'))
|
||||||
|
|
||||||
@section('main_class', 'shorts-view-page')
|
@section('main_class', 'shorts-view-page')
|
||||||
|
|
||||||
@@ -64,6 +64,20 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Poster shim shown behind the <video> (and visible on inactive slots).
|
||||||
|
Keeps the slot filled with content when the shared player node has
|
||||||
|
moved to a different slot. */
|
||||||
|
.short-poster {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
/* Hidden preloader elements that fetch metadata for the next 1-2 shorts
|
||||||
|
without rendering anything visible. preload="metadata" is the
|
||||||
|
smallest-possible warmup; the browser pulls the moov atom + first
|
||||||
|
keyframe, enough that the switch to playback is instant. */
|
||||||
|
.short-preloader { display: none; }
|
||||||
|
|
||||||
/* ── Tap-to-pause icon ── */
|
/* ── Tap-to-pause icon ── */
|
||||||
.short-pause-icon {
|
.short-pause-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -337,18 +351,21 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Video player --}}
|
{{-- Video player wrap. The actual <video> element is a SINGLE shared
|
||||||
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
|
node (defined once at the bottom of the feed) that gets moved
|
||||||
|
between slots as the active short changes. Each slot carries its
|
||||||
|
own src + poster on data- attributes and the slot's inner div
|
||||||
|
shows the poster until the player docks here. --}}
|
||||||
|
<div class="short-player-wrap" id="wrap-{{ $index }}"
|
||||||
|
data-src="{{ $src }}"
|
||||||
|
data-poster="{{ $thumb }}"
|
||||||
|
onclick="toggleShortPlay({{ $index }})">
|
||||||
|
|
||||||
<video
|
{{-- Poster shim: covers the slot before the shared <video> moves in.
|
||||||
id="sv-{{ $index }}"
|
Also stays visible on inactive slots so the viewport doesn't
|
||||||
src="{{ $src }}"
|
go blank when the user is between shorts. --}}
|
||||||
poster="{{ $thumb }}"
|
<div class="short-poster"
|
||||||
loop
|
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
|
||||||
playsinline
|
|
||||||
preload="{{ $loop->first ? 'auto' : 'none' }}"
|
|
||||||
style="width:100%;height:100%;object-fit:cover;">
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{{-- Pause icon --}}
|
{{-- Pause icon --}}
|
||||||
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
||||||
@@ -387,11 +404,14 @@
|
|||||||
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{-- Comments --}}
|
{{-- Comments — opens the side panel on desktop, bottom sheet on
|
||||||
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
|
mobile (CSS does the breakpoint; the button toggles a shared
|
||||||
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
|
.shorts-comments-pane element populated via fetch). --}}
|
||||||
|
<button type="button" class="short-action-btn"
|
||||||
|
onclick="shortOpenComments('{{ $video->getRouteKey() }}')">
|
||||||
|
<div class="short-action-icon"><i class="bi bi-chat-fill" aria-hidden="true"></i></div>
|
||||||
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
{{-- Share --}}
|
{{-- Share --}}
|
||||||
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
||||||
@@ -426,36 +446,182 @@
|
|||||||
|
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
||||||
|
{{-- Single shared <video> moved between slots as the active short changes.
|
||||||
|
Starts out detached (parked here in the DOM); the IntersectionObserver
|
||||||
|
relocates it into .short-player-wrap of the active slot. --}}
|
||||||
|
<video id="svGlobal"
|
||||||
|
loop playsinline
|
||||||
|
preload="metadata"
|
||||||
|
style="width:100%;height:100%;object-fit:cover;display:block;position:absolute;inset:0;">
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{{-- Preloaders for the next 1-2 shorts. Hidden; preload="metadata" is
|
||||||
|
enough that switching to playback is instant. We assign srcs
|
||||||
|
dynamically — defining them here keeps the DOM shape stable. --}}
|
||||||
|
<video id="svPre1" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
<video id="svPre2" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
|
||||||
|
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
||||||
|
Populated via fetch of the regular watch page's #ytcSection block so
|
||||||
|
all the comment features (likes, replies, translate, heart/pin) work
|
||||||
|
identically. --}}
|
||||||
|
<div id="shortsCommentsPane" class="shorts-comments-pane" aria-hidden="true">
|
||||||
|
<div class="shorts-comments-head">
|
||||||
|
<span>Comments</span>
|
||||||
|
<button type="button" class="shorts-comments-close" onclick="shortCloseComments()" aria-label="Close comments">
|
||||||
|
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="shorts-comments-body" id="shortsCommentsBody">
|
||||||
|
<div class="shorts-comments-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="shortsCommentsBackdrop" class="shorts-comments-backdrop" onclick="shortCloseComments()"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shorts-comments-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
|
z-index: 59; display: none;
|
||||||
|
}
|
||||||
|
.shorts-comments-backdrop.visible { display: block; }
|
||||||
|
.shorts-comments-pane {
|
||||||
|
position: fixed;
|
||||||
|
right: 0; top: 56px; bottom: 0; width: 420px;
|
||||||
|
background: var(--bg-dark); color: var(--text-primary);
|
||||||
|
box-shadow: -4px 0 24px rgba(0,0,0,.4);
|
||||||
|
z-index: 60; display: none; flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.shorts-comments-pane.visible { display: flex; }
|
||||||
|
.shorts-comments-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.shorts-comments-close {
|
||||||
|
background: none; border: none; color: var(--text-primary);
|
||||||
|
cursor: pointer; font-size: 16px; padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.shorts-comments-close:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.shorts-comments-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.shorts-comments-loading {
|
||||||
|
text-align: center; padding: 40px 16px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: bottom sheet instead of right drawer. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shorts-comments-pane {
|
||||||
|
left: 0; right: 0; top: auto; bottom: 56px;
|
||||||
|
width: 100%; height: 70vh;
|
||||||
|
border-left: none; border-top: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const main = document.getElementById('main');
|
const main = document.getElementById('main');
|
||||||
let muted = false;
|
const video = document.getElementById('svGlobal');
|
||||||
|
const pre1 = document.getElementById('svPre1');
|
||||||
|
const pre2 = document.getElementById('svPre2');
|
||||||
|
// 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;
|
let current = -1;
|
||||||
|
|
||||||
|
// Build an ordered list of slot wraps so neighbor lookup is a plain
|
||||||
|
// array index — we need to know "the slot at position N+1" to know
|
||||||
|
// which short to preload next.
|
||||||
|
const wraps = Array.from(document.querySelectorAll('.short-player-wrap'));
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
|
||||||
|
function _dockVideoIn(wrap) {
|
||||||
|
// Move the shared <video> element into the active wrap. The browser
|
||||||
|
// keeps the active media context across the move, so playback
|
||||||
|
// doesn't restart just because we re-parented the node.
|
||||||
|
if (video.parentElement !== wrap) {
|
||||||
|
// Append after the poster so the poster acts as background
|
||||||
|
// while the video decodes its first frame.
|
||||||
|
wrap.appendChild(video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _primePreloaders(idx) {
|
||||||
|
// Preload metadata for the next 1-2 shorts so switching feels
|
||||||
|
// instant. preload="metadata" grabs just the moov atom + first
|
||||||
|
// keyframe; the browser doesn't fetch the whole file.
|
||||||
|
[pre1, pre2].forEach((pre, i) => {
|
||||||
|
const target = wraps[idx + 1 + i];
|
||||||
|
const nextSrc = target?.dataset.src || '';
|
||||||
|
if (pre.src !== nextSrc) pre.src = nextSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseShort(idx) {
|
||||||
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
|
if (video && video.parentElement === wrap) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
}
|
||||||
|
if (wrap) wrap.classList.remove('paused');
|
||||||
|
const prog = document.getElementById('prog-' + idx);
|
||||||
|
if (prog) prog.style.width = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
// ── IntersectionObserver — auto-play visible short ─────────
|
// ── IntersectionObserver — auto-play visible short ─────────
|
||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const item = entry.target;
|
const item = entry.target;
|
||||||
const idx = parseInt(item.dataset.index);
|
const idx = parseInt(item.dataset.index);
|
||||||
const video = document.getElementById('sv-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||||
if (current !== -1 && current !== idx) pauseShort(current);
|
if (current !== -1 && current !== idx) pauseShort(current);
|
||||||
current = idx;
|
current = idx;
|
||||||
|
|
||||||
|
// ── Reflect the active short in the URL (no history entry) ──
|
||||||
|
try {
|
||||||
|
const vid = item.dataset.id;
|
||||||
|
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_dockVideoIn(wrap);
|
||||||
|
const nextSrc = wrap.dataset.src || '';
|
||||||
|
if (video.src !== nextSrc) video.src = nextSrc;
|
||||||
|
video.poster = wrap.dataset.poster || '';
|
||||||
|
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
video.volume = 1;
|
video.volume = 1;
|
||||||
video.play().catch(() => {
|
video.play().catch(() => {
|
||||||
// autoplay blocked — start muted then unmute
|
// autoplay blocked — fall back to muted
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
muted = true;
|
muted = true;
|
||||||
|
localStorage.setItem('shortsMuted', '1');
|
||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
document.getElementById('wrap-' + idx).classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
|
|
||||||
|
_primePreloaders(idx);
|
||||||
} else {
|
} else {
|
||||||
pauseShort(idx);
|
pauseShort(idx);
|
||||||
}
|
}
|
||||||
@@ -466,9 +632,8 @@
|
|||||||
|
|
||||||
// ── Play / pause on tap ────────────────────────────────────
|
// ── Play / pause on tap ────────────────────────────────────
|
||||||
window.toggleShortPlay = function(idx) {
|
window.toggleShortPlay = function(idx) {
|
||||||
const video = document.getElementById('sv-' + idx);
|
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap || video.parentElement !== wrap) return;
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
wrap.classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
@@ -478,30 +643,23 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function pauseShort(idx) {
|
// ── Progress bar — a single listener on the shared video updates the
|
||||||
const video = document.getElementById('sv-' + idx);
|
// bar in whichever slot it's currently docked. --
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
|
||||||
if (video) { video.pause(); video.currentTime = 0; }
|
|
||||||
if (wrap) wrap.classList.remove('paused');
|
|
||||||
const prog = document.getElementById('prog-' + idx);
|
|
||||||
if (prog) prog.style.width = '0%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress bars ──────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.short-item video').forEach(video => {
|
|
||||||
const idx = video.id.replace('sv-', '');
|
|
||||||
video.addEventListener('timeupdate', () => {
|
video.addEventListener('timeupdate', () => {
|
||||||
if (!video.duration) return;
|
if (!video.duration) return;
|
||||||
|
const wrap = video.parentElement;
|
||||||
|
if (!wrap || !wrap.classList.contains('short-player-wrap')) return;
|
||||||
|
const idx = wrap.id.replace('wrap-', '');
|
||||||
const prog = document.getElementById('prog-' + idx);
|
const prog = document.getElementById('prog-' + idx);
|
||||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mute toggle ────────────────────────────────────────────
|
// ── Mute toggle ────────────────────────────────────────────
|
||||||
window.toggleMute = function(idx) {
|
window.toggleMute = function(idx) {
|
||||||
muted = !muted;
|
muted = !muted;
|
||||||
const video = document.getElementById('sv-' + idx);
|
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||||
if (video) video.muted = muted;
|
// The shared video is the only one playing; mute it directly.
|
||||||
|
video.muted = muted;
|
||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -528,6 +686,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 ───────────────────────────────────────────────────
|
// ── Like ───────────────────────────────────────────────────
|
||||||
window.shortLike = function(videoId) {
|
window.shortLike = function(videoId) {
|
||||||
@auth
|
@auth
|
||||||
@@ -552,6 +726,37 @@
|
|||||||
@endauth
|
@endauth
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Comments pane ──────────────────────────────────────
|
||||||
|
window.shortOpenComments = function(videoKey) {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
const body = document.getElementById('shortsCommentsBody');
|
||||||
|
if (!pane || !body) return;
|
||||||
|
body.innerHTML = '<div class="shorts-comments-loading">Loading…</div>';
|
||||||
|
pane.classList.add('visible');
|
||||||
|
pane.setAttribute('aria-hidden', 'false');
|
||||||
|
backdrop?.classList.add('visible');
|
||||||
|
fetch('/videos/' + videoKey, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const section = doc.getElementById('ytcSection');
|
||||||
|
if (section) { body.innerHTML = ''; body.appendChild(section); }
|
||||||
|
else { body.innerHTML = '<div class="shorts-comments-loading">Comments unavailable.</div>'; }
|
||||||
|
})
|
||||||
|
.catch(() => { body.innerHTML = '<div class="shorts-comments-loading">Could not load comments.</div>'; });
|
||||||
|
};
|
||||||
|
window.shortCloseComments = function() {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
pane?.classList.remove('visible');
|
||||||
|
pane?.setAttribute('aria-hidden', 'true');
|
||||||
|
backdrop?.classList.remove('visible');
|
||||||
|
};
|
||||||
|
|
||||||
// ── Share ──────────────────────────────────────────────────
|
// ── Share ──────────────────────────────────────────────────
|
||||||
window.shortShare = function(videoKey, title) {
|
window.shortShare = function(videoKey, title) {
|
||||||
const url = window.location.origin + '/videos/' + videoKey;
|
const url = window.location.origin + '/videos/' + videoKey;
|
||||||
|
|||||||
@@ -836,15 +836,22 @@
|
|||||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||||
<div class="recommended-videos-list">
|
<div class="recommended-videos-list">
|
||||||
@foreach ($recommendedVideos as $recVideo)
|
@foreach ($recommendedVideos as $recVideo)
|
||||||
<div class="sidebar-video-card"
|
{{-- Real <a href> cards so right-click / open-in-new-tab /
|
||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
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;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}" width="168" height="94"
|
||||||
|
loading="lazy" decoding="async">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
<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
|
@endif
|
||||||
@if ($recVideo->duration)
|
@if ($recVideo->duration)
|
||||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||||
@@ -852,14 +859,15 @@
|
|||||||
@if ($recVideo->is_shorts)
|
@if ($recVideo->is_shorts)
|
||||||
<span class="yt-shorts-badge"
|
<span class="yt-shorts-badge"
|
||||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
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>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-info">
|
<div class="sidebar-info">
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-title">
|
||||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
<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) }}
|
{{ Str::limit($recVideo->title, 60) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
@@ -868,7 +876,7 @@
|
|||||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -177,9 +177,13 @@
|
|||||||
<div class="trending-header">
|
<div class="trending-header">
|
||||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||||
<div class="trending-filters">
|
<div class="trending-filters">
|
||||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
{{-- Hours line up with the labels: 24 = Today, 168 = This Week
|
||||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
(7×24), 720 = This Month (30×24). The old 24/48/168 trio
|
||||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,10 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
|
||||||
|
|
||||||
.sidebar-video-card.current-video {
|
.sidebar-video-card.current-video {
|
||||||
background: rgba(239,68,68,.12);
|
background: rgba(239,68,68,.12);
|
||||||
@@ -977,9 +980,13 @@
|
|||||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||||
<div class="recommended-videos-list" id="recList">
|
<div class="recommended-videos-list" id="recList">
|
||||||
@foreach ($recommendedVideos as $recVideo)
|
@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) }}"
|
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;">
|
style="cursor:pointer;">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
@@ -1013,7 +1020,7 @@
|
|||||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -317,14 +317,12 @@
|
|||||||
|
|
||||||
/* No rotation animation */
|
/* No rotation animation */
|
||||||
|
|
||||||
/* Hide sidebar by default */
|
{{-- Dead-code removal (2026-10-21 audit): the previous `.events-sidebar
|
||||||
.events-sidebar {
|
{ display: none }` + `.show { display: flex }` pair here was
|
||||||
display: none;
|
overridden further down (search for the `display: none !important`
|
||||||
}
|
definition under /* ===== Match Events Sidebar ===== */). The
|
||||||
|
!important forms silently won every cascade; this pair never
|
||||||
.events-sidebar.show {
|
rendered. --}}
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Playlist Navigation Controls ===== */
|
/* ===== Playlist Navigation Controls ===== */
|
||||||
.playlist-controls {
|
.playlist-controls {
|
||||||
@@ -2009,15 +2007,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1300px) {
|
{{-- Dead-code removal (2026-10-21 audit): a prior
|
||||||
.yt-sidebar-container {
|
@media (max-width: 1300px) block sized .yt-sidebar-container +
|
||||||
width: 300px;
|
.events-sidebar to 300 px. The immediately-following
|
||||||
}
|
@media (max-width: 1300px) rule overrides both to 100% with
|
||||||
|
!important as part of the stacking layout, so the 300 px pair
|
||||||
.events-sidebar {
|
never applied. --}}
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Narrow desktop: too little room for a side rail ──────────────
|
/* ── Narrow desktop: too little room for a side rail ──────────────
|
||||||
Between 992px and 1300px the left nav (240px) and the 300px rail
|
Between 992px and 1300px the left nav (240px) and the 300px rail
|
||||||
@@ -2418,6 +2413,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- ===== END EVENT HEADER ===== -->
|
<!-- ===== END EVENT HEADER ===== -->
|
||||||
|
|
||||||
|
{{-- Channel row — the generic + music watch pages render this
|
||||||
|
just under the title. The match page used to omit it so
|
||||||
|
the only way to reach the uploader's channel from a match
|
||||||
|
video was the small avatar hidden inside the Match Overview
|
||||||
|
section. --}}
|
||||||
|
<x-channel-row :video="$video" />
|
||||||
|
|
||||||
@include('videos.partials.description-box', ['video' => $video])
|
@include('videos.partials.description-box', ['video' => $video])
|
||||||
|
|
||||||
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
||||||
@@ -3144,9 +3146,13 @@
|
|||||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||||
<div class="recommended-videos-list" id="recList">
|
<div class="recommended-videos-list" id="recList">
|
||||||
@foreach ($recommendedVideos as $recVideo)
|
@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) }}"
|
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;">
|
style="cursor:pointer;">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
@@ -3181,7 +3187,7 @@
|
|||||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -307,7 +307,12 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
transition: background .15s;
|
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 {
|
.sidebar-video-card.current-video {
|
||||||
background: rgba(239,68,68,.12);
|
background: rgba(239,68,68,.12);
|
||||||
@@ -967,9 +972,13 @@
|
|||||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||||
<div class="recommended-videos-list" id="recList">
|
<div class="recommended-videos-list" id="recList">
|
||||||
@foreach ($recommendedVideos as $recVideo)
|
@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) }}"
|
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;">
|
style="cursor:pointer;">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
@@ -1003,7 +1012,7 @@
|
|||||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', 'Welcome - ' . config('app.name'))
|
@section('title', 'Welcome | ' . config('app.name'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ Route::middleware('guest')->group(function () {
|
|||||||
|
|
||||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
|
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
|
||||||
Route::post('login', [AuthenticatedSessionController::class, 'store'])
|
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');
|
->name('login.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+71
-1
@@ -24,9 +24,20 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
|
|||||||
|
|
||||||
// Video routes - public
|
// Video routes - public
|
||||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
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('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||||
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
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');
|
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||||
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
|
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
|
||||||
Route::get('/videos/{numericId}', function ($numericId) {
|
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::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::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}/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/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::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');
|
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
|
||||||
@@ -119,6 +131,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||||
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
||||||
|
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe + notifications — auth but NOT email verification required
|
// Subscribe + notifications — auth but NOT email verification required
|
||||||
@@ -137,11 +150,14 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
|
|||||||
|
|
||||||
// Comment routes
|
// Comment routes
|
||||||
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
|
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::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
|
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||||
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
||||||
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
|
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
|
||||||
|
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
|
||||||
|
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
|
||||||
});
|
});
|
||||||
|
|
||||||
// User routes
|
// User routes
|
||||||
@@ -159,6 +175,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::get('/history', [UserController::class, 'history'])->name('history');
|
Route::get('/history', [UserController::class, 'history'])->name('history');
|
||||||
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
|
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
|
||||||
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
||||||
|
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
|
||||||
|
|
||||||
|
// Creator studio
|
||||||
|
Route::get('/studio', [\App\Http\Controllers\StudioController::class, 'index'])->name('studio');
|
||||||
|
Route::post('/studio/bulk/visibility', [\App\Http\Controllers\StudioController::class, 'bulkVisibility'])->name('studio.bulkVisibility');
|
||||||
|
Route::post('/studio/bulk/delete', [\App\Http\Controllers\StudioController::class, 'bulkDelete'])->name('studio.bulkDelete');
|
||||||
|
|
||||||
// Logout all other devices
|
// Logout all other devices
|
||||||
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
|
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
|
||||||
@@ -171,6 +193,54 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
||||||
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
||||||
|
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
|
||||||
|
->name('channel.videos.more');
|
||||||
|
// 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.
|
||||||
|
// ─── Live streaming ─────────────────────────────────────
|
||||||
|
use App\Http\Controllers\LiveAuthController;
|
||||||
|
use App\Http\Controllers\LiveStreamController;
|
||||||
|
|
||||||
|
Route::get('/live', [LiveStreamController::class, 'index'])->name('live.index');
|
||||||
|
Route::get('/live/new', [LiveStreamController::class, 'create'])->name('live.create');
|
||||||
|
Route::post('/live', [LiveStreamController::class, 'store'])->name('live.store')->middleware('throttle:10,1');
|
||||||
|
|
||||||
|
Route::get('/live/{stream}/broadcast', [LiveStreamController::class, 'broadcast'])->name('live.broadcast');
|
||||||
|
Route::post('/live/{stream}/token', [LiveStreamController::class, 'token'])->name('live.token')->middleware('throttle:30,1');
|
||||||
|
Route::post('/live/{stream}/stop', [LiveStreamController::class, 'stop'])->name('live.stop')->middleware('throttle:30,1');
|
||||||
|
|
||||||
|
Route::get('/live/{stream}', [LiveStreamController::class, 'watch'])->name('live.watch');
|
||||||
|
Route::get('/live/{stream}/status', [LiveStreamController::class, 'status'])->name('live.status')->middleware('throttle:120,1');
|
||||||
|
|
||||||
|
// Live chat — polling by viewers, send by signed-in users (rate-limited).
|
||||||
|
Route::get('/live/{stream}/chat', [LiveStreamController::class, 'chatPoll'])
|
||||||
|
->name('live.chat.poll')->middleware('throttle:120,1');
|
||||||
|
Route::post('/live/{stream}/chat', [LiveStreamController::class, 'chatSend'])
|
||||||
|
->name('live.chat.send')->middleware('throttle:60,1');
|
||||||
|
|
||||||
|
Route::post('/api/live/auth', [LiveAuthController::class, 'authenticate'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
Route::post('/api/live/hook', [LiveAuthController::class, 'hook'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
|
||||||
|
Route::get('/@{handle}', [UserController::class, 'channel'])
|
||||||
|
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
||||||
|
->name('channel.handle');
|
||||||
|
|
||||||
|
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
|
||||||
|
// Shorts, etc. These bridge to the single-page channel view by forwarding
|
||||||
|
// the tab name as a hash — the channel.blade.php JS already handles
|
||||||
|
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
|
||||||
|
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
|
||||||
|
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
|
||||||
|
if (! in_array($tab, $allowed, true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$user = \App\Models\User::where('username', $handle)->firstOrFail();
|
||||||
|
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
|
||||||
|
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
|
||||||
|
->name('channel.handle.tab');
|
||||||
|
|
||||||
// Post routes - auth required
|
// Post routes - auth required
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin polish follow-ups to Phase 7 — storage math, pagination controls,
|
||||||
|
* HTML-stripped descriptions and the grouped error log viewer.
|
||||||
|
*/
|
||||||
|
class AdminPolishTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pagination partial: First + Last buttons ─────
|
||||||
|
|
||||||
|
public function test_pagination_partial_renders_first_and_last_controls(): void
|
||||||
|
{
|
||||||
|
$paginator = new LengthAwarePaginator(
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
total: 100,
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 5,
|
||||||
|
options: ['path' => '/videos']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = view('partials.pagination', [
|
||||||
|
'paginator' => $paginator,
|
||||||
|
'elements' => $paginator->linkCollection()->toArray(),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('aria-label="First page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Last page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Previous page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Next page"', $html);
|
||||||
|
|
||||||
|
// The anchors should link to page 1 and page 10 respectively.
|
||||||
|
$this->assertStringContainsString('href="/videos?page=1"', $html);
|
||||||
|
$this->assertStringContainsString('href="/videos?page=10"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pagination_disables_first_on_page_one(): void
|
||||||
|
{
|
||||||
|
// Two-page paginator — if there's only one page the partial
|
||||||
|
// short-circuits via @if($paginator->hasPages()) and renders
|
||||||
|
// nothing, which isn't what we're testing.
|
||||||
|
$paginator = new LengthAwarePaginator(
|
||||||
|
items: ['a'],
|
||||||
|
total: 20,
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
options: ['path' => '/videos']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = view('partials.pagination', [
|
||||||
|
'paginator' => $paginator,
|
||||||
|
'elements' => $paginator->linkCollection()->toArray(),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
// On page 1 the First button is a disabled span, not an anchor.
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/<span[^>]+class="pag-item disabled"[^>]+title="First page"/',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin videos list: HTML-stripped description ─
|
||||||
|
|
||||||
|
public function test_admin_video_description_renders_without_html_tags(): void
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||||
|
'role' => 'super_admin',
|
||||||
|
]);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'The Full Video Title That Will Be Ellipsized',
|
||||||
|
'description' => '<p>Hello <strong>world</strong> <a href="#">link</a></p>',
|
||||||
|
'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)),
|
||||||
|
]);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$html = $this->get('/admin/videos')->getContent();
|
||||||
|
|
||||||
|
// Admin list must render plain text, not literal <p> / <strong>.
|
||||||
|
$this->assertStringNotContainsString('<p>Hello', $html,
|
||||||
|
'Description must be stripped of HTML tags before display.');
|
||||||
|
$this->assertStringContainsString('Hello world link', $html,
|
||||||
|
'Stripped text should still be visible.');
|
||||||
|
// Title tooltip in both the title column and description preview.
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'title="The Full Video Title That Will Be Ellipsized"',
|
||||||
|
$html,
|
||||||
|
'Admin title column must carry a title="" attribute for the full name.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard: Ready label ──────────────────────
|
||||||
|
|
||||||
|
public function test_dashboard_ready_stat_card_uses_the_new_label(): void
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'username' => 'ad'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
'role' => 'super_admin',
|
||||||
|
]);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$html = $this->get('/admin/dashboard')->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('>Ready<', $html);
|
||||||
|
$this->assertStringNotContainsString('>Live Videos<', $html,
|
||||||
|
'The old "Live Videos" stat-card label should be gone.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the bulk cleanup of deferred items:
|
||||||
|
* end-screen markup, C hotkey, guest subscribe prompt, /@handle tab aliases,
|
||||||
|
* channel videos cursor endpoint, shorts comments pane, studio skeleton.
|
||||||
|
*/
|
||||||
|
class DeferredCleanupTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Player ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_player_component_ships_the_end_screen_markup(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('id="ytpEndScreen"', $html);
|
||||||
|
$this->assertStringContainsString('ytp-end-countdown', $html, 'countdown ring class hook');
|
||||||
|
$this->assertStringContainsString('id="ytpEndRing"', $html);
|
||||||
|
$this->assertStringContainsString('id="ytpEndCancel"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_player_binds_c_hotkey_to_lyrics_button(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("case 'c':", $html);
|
||||||
|
$this->assertStringContainsString('ytpLyricsBtn', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guest Subscribe prompt ────────────────────
|
||||||
|
|
||||||
|
public function test_guest_subscribe_button_preserves_intended_url(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-actions', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('?intended=', $html,
|
||||||
|
'Guest Subscribe button must redirect to login with ?intended=.');
|
||||||
|
$this->assertStringContainsString('encodeURIComponent', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── /@handle tab aliases ───────────────────────
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_302_redirects_to_hash_url(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'hta-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$r = $this->get('/@' . $u->username . '/videos');
|
||||||
|
$r->assertStatus(302);
|
||||||
|
$this->assertStringEndsWith('#videos', $r->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_404s_on_unknown_tab(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'htn-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$this->get('/@' . $u->username . '/nonsense')->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel videos cursor endpoint ─────────────
|
||||||
|
|
||||||
|
public function test_channel_videos_more_returns_cursor_json(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
// Twice the page size so has_more is true.
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$this->makeVideo($u, ['title' => 'Vid '.$i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = $this->getJson(route('channel.videos.more', ['username' => $u->username]));
|
||||||
|
$r->assertOk()
|
||||||
|
->assertJsonStructure(['items', 'next_cursor', 'has_more'])
|
||||||
|
->assertJson(['has_more' => true]);
|
||||||
|
$this->assertStringContainsString('yt-video-card', $r->json('items'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Studio ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_studio_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->get(route('studio'))->assertStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_renders_only_my_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['title' => 'MyStudioVideo']);
|
||||||
|
$theirs = $this->makeVideo($other, ['title' => 'TheirVideo']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$html = $this->get(route('studio'))->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('MyStudioVideo', $html);
|
||||||
|
$this->assertStringNotContainsString('TheirVideo', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_visibility_refuses_other_users_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['visibility' => 'public']);
|
||||||
|
$theirs = $this->makeVideo($other, ['visibility' => 'public']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkVisibility'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
'visibility' => 'private',
|
||||||
|
])->assertOk()->assertJson(['updated' => 1]);
|
||||||
|
|
||||||
|
$this->assertEquals('private', $mine->fresh()->visibility);
|
||||||
|
$this->assertEquals('public', $theirs->fresh()->visibility,
|
||||||
|
'A crafted request body must not flip another user\'s video.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_delete_scoped_to_owner(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me);
|
||||||
|
$theirs = $this->makeVideo($other);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkDelete'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
])->assertOk()->assertJson(['deleted' => 1]);
|
||||||
|
|
||||||
|
$this->assertNull(Video::find($mine->id));
|
||||||
|
$this->assertNotNull(Video::find($theirs->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeVideo(User $u, array $attrs = []): Video
|
||||||
|
{
|
||||||
|
return Video::create(array_merge([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'T',
|
||||||
|
'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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video dislike toggle + creator heart + creator pin.
|
||||||
|
*
|
||||||
|
* The in-memory DB these tests run against gets the two post-audit migrations
|
||||||
|
* applied automatically by RefreshDatabase, so no manual migrate step is
|
||||||
|
* required to exercise the full feature.
|
||||||
|
*/
|
||||||
|
class DislikeHeartPinTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dislike toggle ──────────────────────────────
|
||||||
|
|
||||||
|
public function test_toggle_dislike_adds_then_removes_the_row(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => true, 'dislike_count' => 1]);
|
||||||
|
$this->assertDatabaseHas('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => false, 'dislike_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disliking_clears_an_existing_like(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$video->likes()->attach($viewer->id);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => true, 'liked' => false, 'like_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_likes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_liking_clears_an_existing_dislike(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$video->dislikes()->attach($viewer->id);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleLike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['liked' => true, 'disliked' => false, 'like_count' => 1, 'dislike_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heart ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_owner_can_heart_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$comment = Comment::create([
|
||||||
|
'user_id' => $viewer->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'nice video!',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['hearted' => true]);
|
||||||
|
$this->assertEquals(true, $comment->fresh()->hearted);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['hearted' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_owner_cannot_heart_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$comment = Comment::create([
|
||||||
|
'user_id' => $viewer->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'nice video!',
|
||||||
|
]);
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))->assertStatus(403);
|
||||||
|
$this->assertEquals(false, $comment->fresh()->hearted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pin ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_owner_pins_at_most_one_comment_at_a_time(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$a = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'A']);
|
||||||
|
$b = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'B']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $a))->assertOk()->assertJson(['pinned' => true]);
|
||||||
|
$this->assertTrue((bool) $a->fresh()->pinned);
|
||||||
|
$this->assertFalse((bool) $b->fresh()->pinned);
|
||||||
|
|
||||||
|
// Pinning B must unpin A.
|
||||||
|
$this->postJson(route('comments.pin', $b))->assertOk()->assertJson(['pinned' => true]);
|
||||||
|
$this->assertFalse((bool) $a->fresh()->pinned);
|
||||||
|
$this->assertTrue((bool) $b->fresh()->pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_pin_a_reply(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$parent = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'top']);
|
||||||
|
$reply = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'parent_id' => $parent->id, 'body' => 'r']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $reply))->assertStatus(422);
|
||||||
|
$this->assertFalse((bool) $reply->fresh()->pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_owner_cannot_pin_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$comment = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'self']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $comment))->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pinned comments sort first in the component ──
|
||||||
|
|
||||||
|
public function test_pinned_comments_render_before_newer_unpinned_ones(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$pinned = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'OLDPINNED', 'pinned' => true]);
|
||||||
|
// Date it older than the second one so sort would normally send it to the bottom.
|
||||||
|
$pinned->created_at = now()->subDays(5); $pinned->save();
|
||||||
|
Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'NEWFRESH']);
|
||||||
|
|
||||||
|
$html = view('components.video-comments', ['video' => $video])->render();
|
||||||
|
// Match on the unique comment-id markup so count/header text can't
|
||||||
|
// interfere with the order check.
|
||||||
|
$pOld = strpos($html, 'id="comment-' . $pinned->id . '"');
|
||||||
|
$pNew = strpos($html, 'id="comment-' . ($pinned->id + 1) . '"');
|
||||||
|
|
||||||
|
$this->assertNotFalse($pOld);
|
||||||
|
$this->assertNotFalse($pNew);
|
||||||
|
$this->assertLessThan($pNew, $pOld, 'Pinned comment must render before a newer unpinned one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeOwnerViewerVideo(): array
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create(['username' => 'own-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||||
|
$viewer = User::factory()->create(['username' => 'vwr-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'test',
|
||||||
|
'description' => '',
|
||||||
|
'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 [$owner, $viewer, $video];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,29 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
class ExampleTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A basic test example.
|
* The home page must render for an anonymous visitor on an empty database.
|
||||||
|
*
|
||||||
|
* Previously 500'd because this test skipped RefreshDatabase, so the
|
||||||
|
* home controller tried to query a `videos` table that didn't exist in
|
||||||
|
* the in-memory testing DB.
|
||||||
*/
|
*/
|
||||||
public function test_the_application_returns_a_successful_response(): void
|
public function test_the_application_returns_a_successful_response(): void
|
||||||
{
|
{
|
||||||
$response = $this->get('/');
|
// phpunit.xml + .env.testing + TestCase::assertSafeTestDatabase() point
|
||||||
|
// this at :memory: — matching the trio every Phase test uses.
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$this->get('/')->assertStatus(200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\LiveChatMessage;
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live polish regression tests. Covers:
|
||||||
|
* - Private visibility accepted by /live/new validation.
|
||||||
|
* - Ended-state watch page shows "Stream ended" + duration.
|
||||||
|
* - Chat poll endpoint returns everything newer than ?since=.
|
||||||
|
* - Chat send endpoint rejects guests, non-live streams, and
|
||||||
|
* anyone but the owner on a private stream.
|
||||||
|
*/
|
||||||
|
class LivePolishTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private visibility accepted ─────────────────
|
||||||
|
|
||||||
|
public function test_create_live_stream_accepts_private_visibility(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$this->actingAs($u);
|
||||||
|
|
||||||
|
$this->post(route('live.store'), [
|
||||||
|
'title' => 'My private test stream',
|
||||||
|
'description' => 'just testing',
|
||||||
|
'visibility' => 'private',
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('livestreams', [
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'My private test stream',
|
||||||
|
'visibility' => 'private',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ended state renders "Stream ended" ──────────
|
||||||
|
|
||||||
|
public function test_ended_stream_watch_page_shows_stream_ended(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, [
|
||||||
|
'status' => LiveStream::STATUS_ENDED,
|
||||||
|
'started_at' => now()->subMinutes(10),
|
||||||
|
'ended_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
|
||||||
|
$this->assertStringContainsString('Stream ended', $html);
|
||||||
|
$this->assertStringNotContainsString('Waiting for the broadcast to start', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel name is a link ──────────────────────
|
||||||
|
|
||||||
|
public function test_watch_page_channel_name_is_a_real_link(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
$expected = route('channel', $u->channel);
|
||||||
|
|
||||||
|
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('href="' . $expected . '"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat poll ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_chat_poll_returns_messages_newer_than_since(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
|
||||||
|
$a = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'hello', 'created_at' => now()]);
|
||||||
|
$b = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'world', 'created_at' => now()]);
|
||||||
|
|
||||||
|
$this->getJson(route('live.chat.poll', $s) . '?since=' . $a->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'messages')
|
||||||
|
->assertJsonPath('messages.0.body', 'world');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat send ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_chat_send_rejects_guests(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
|
||||||
|
// postJson sets Accept: application/json so the auth middleware
|
||||||
|
// returns 401 instead of redirecting to login.
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
|
||||||
|
->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_chat_send_rejects_offline_streams(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_ENDED]);
|
||||||
|
$this->actingAs($u);
|
||||||
|
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_stream_chat_rejects_non_owner(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser();
|
||||||
|
$viewer = $this->makeUser();
|
||||||
|
$s = $this->makeStream($owner, ['status' => LiveStream::STATUS_LIVE, 'visibility' => 'private']);
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'sneak'])
|
||||||
|
->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => 'lu'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeStream(User $u, array $attrs = []): LiveStream
|
||||||
|
{
|
||||||
|
return LiveStream::create(array_merge([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'public_id' => substr(bin2hex(random_bytes(10)), 0, 16),
|
||||||
|
'title' => 'Test stream',
|
||||||
|
'description' => '',
|
||||||
|
'status' => LiveStream::STATUS_IDLE,
|
||||||
|
'visibility' => 'public',
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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".'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\SportsMatch;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-audit polish — the deferred items we tackled in bulk after Phase 7:
|
||||||
|
* match channel row, read-more clamp, nested-reply prefill, player hotkeys,
|
||||||
|
* cursor pagination on /liked and /history.
|
||||||
|
*/
|
||||||
|
class PostAuditPolishTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Match page: channel row ─────────────────────
|
||||||
|
|
||||||
|
public function test_match_watch_page_renders_the_channel_row(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser('match-owner');
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'MatchTitle',
|
||||||
|
'description' => '',
|
||||||
|
'filename' => 'users/x/sports/y/video.mp4',
|
||||||
|
'path' => 'users/x/sports/y/video.mp4',
|
||||||
|
'type' => 'match',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
// Minimal sports_match so the match blade renders without null checks exploding.
|
||||||
|
SportsMatch::create([
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'MatchTitle',
|
||||||
|
'participants' => [],
|
||||||
|
'sport' => 'karate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get(route('videos.show', $video))->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('channel-row', $html,
|
||||||
|
'Match watch page must render the shared channel row component.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Comment reply: nested handle button present ──
|
||||||
|
|
||||||
|
public function test_reply_to_reply_button_carries_data_handle(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser('replyowner');
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'Vid',
|
||||||
|
'description'=> '',
|
||||||
|
'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)),
|
||||||
|
]);
|
||||||
|
$parent = Comment::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'top',
|
||||||
|
]);
|
||||||
|
$replier = $this->makeUser('replier');
|
||||||
|
Comment::create([
|
||||||
|
'user_id' => $replier->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'parent_id' => $parent->id,
|
||||||
|
'body' => 'nested',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = view('components.video-comments', ['video' => $video])->render();
|
||||||
|
|
||||||
|
// The nested reply trigger must carry the reply author's handle so the
|
||||||
|
// JS can prefill "@handle " when the parent's reply form opens.
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/_comment-reply-nested-trigger[^"]*"\s+data-parent-id="' . $parent->id . '"\s+data-handle="' . preg_quote($replier->username, '/') . '"/',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Liked feed: cursor pagination ────────────────
|
||||||
|
|
||||||
|
public function test_liked_controller_returns_a_cursor_paginator(): void
|
||||||
|
{
|
||||||
|
// The controller introspection is enough to confirm the pagination
|
||||||
|
// mode — the full HTML path depends on view rendering that costs
|
||||||
|
// 400 KB per test and isn't the behaviour we're asserting on.
|
||||||
|
$user = $this->makeUser('liker');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->getByName('liked');
|
||||||
|
[$class, $method] = explode('@', $route->getActionName());
|
||||||
|
$controller = app($class);
|
||||||
|
|
||||||
|
$response = $controller->$method();
|
||||||
|
$videos = $response->getData()['videos'] ?? null;
|
||||||
|
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
\Illuminate\Pagination\CursorPaginator::class,
|
||||||
|
$videos,
|
||||||
|
'liked() must return a CursorPaginator instance.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Player hotkey code is present ────────────────
|
||||||
|
|
||||||
|
public function test_player_component_handles_shift_dot_hotkey(): void
|
||||||
|
{
|
||||||
|
// The player component accepts a $video context — pass a bare
|
||||||
|
// record so the view renders.
|
||||||
|
$u = $this->makeUser('p');
|
||||||
|
$v = Video::create([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 't',
|
||||||
|
'description'=> '',
|
||||||
|
'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)),
|
||||||
|
]);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("case '>':", $html);
|
||||||
|
$this->assertStringContainsString("case '<':", $html);
|
||||||
|
$this->assertStringContainsString('video.playbackRate', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(string $prefix): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => $prefix.'-'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorts: single reused <video> element + 1-2 next preloaded.
|
||||||
|
*
|
||||||
|
* Validates that the shorts feed markup matches the architecture:
|
||||||
|
* - a single #svGlobal <video> rendered once at the end of the forelse,
|
||||||
|
* - two preloader placeholders (#svPre1 / #svPre2),
|
||||||
|
* - no per-short <video> elements inside .short-player-wrap,
|
||||||
|
* - each slot carries its own data-src + data-poster so the shared player
|
||||||
|
* can swap into position without a full DOM rebuild.
|
||||||
|
*/
|
||||||
|
class ShortsReusedVideoTest 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shorts_feed_renders_a_single_shared_video_element(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create([
|
||||||
|
'username' => 'sh-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||||
|
]);
|
||||||
|
// Three shorts so we can prove only ONE <video id="svGlobal"> is
|
||||||
|
// emitted no matter how many shorts there are.
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
Video::create([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'Short '.$i,
|
||||||
|
'description' => '',
|
||||||
|
'filename' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'path' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'is_shorts' => true,
|
||||||
|
'duration' => 45,
|
||||||
|
'orientation' => 'portrait',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = $this->get(route('videos.shorts'))->assertOk()->getContent();
|
||||||
|
|
||||||
|
// Exactly one shared video element and two preloaders.
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
substr_count($html, 'id="svGlobal"'),
|
||||||
|
'Shorts feed must emit exactly one shared <video id="svGlobal">.'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString('id="svPre1"', $html);
|
||||||
|
$this->assertStringContainsString('id="svPre2"', $html);
|
||||||
|
|
||||||
|
// No per-short video elements inside the slot wraps.
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/short-player-wrap[^>]*>[^<]*<video id="sv-\d+/',
|
||||||
|
$html,
|
||||||
|
'Per-short <video id="sv-{idx}"> elements should be gone.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each slot still carries its own src + poster for the shared
|
||||||
|
// player to pick up on dock.
|
||||||
|
$this->assertStringContainsString('data-src=', $html);
|
||||||
|
$this->assertStringContainsString('data-poster=', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,61 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
|||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
use CreatesApplication;
|
use CreatesApplication;
|
||||||
|
|
||||||
|
/** Output-buffer depth captured before each test, so tearDown can
|
||||||
|
* rewind back to it instead of leaving dangling buffers that PHPUnit
|
||||||
|
* reports as "Test code or tested code did not close its own output
|
||||||
|
* buffers". The depth leaks when a view throws mid-render — common
|
||||||
|
* with Blade's compiled files when a feature test exercises an
|
||||||
|
* error path. */
|
||||||
|
private int $preTestObLevel = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
$this->preTestObLevel = ob_get_level();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Collapse any output buffers the test (or Blade view it rendered)
|
||||||
|
// left open. Without this, each view()->render() call that touches
|
||||||
|
// a Blade partial with its own @once / @push block can leave the
|
||||||
|
// buffer stack one level deep — PHPUnit reports every one of those
|
||||||
|
// tests as "risky".
|
||||||
|
while (ob_get_level() > $this->preTestObLevel) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
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